chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
This commit is contained in:
394
scripts/api_test.sh
Normal file
394
scripts/api_test.sh
Normal file
@@ -0,0 +1,394 @@
|
||||
#!/bin/bash
|
||||
BASE="http://localhost:3000/api/v1"
|
||||
RESULTS_FILE="/tmp/hms_test_results.txt"
|
||||
> "$RESULTS_FILE"
|
||||
|
||||
# Login first
|
||||
LOGIN_RESP=$(curl -s "$BASE/auth/login" -H "Content-Type: application/json" -d '{"username":"admin","password":"Admin@2026"}')
|
||||
TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])" 2>/dev/null)
|
||||
REFRESH_TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['refresh_token'])" 2>/dev/null)
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "LOGIN FAILED - cannot continue"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Login successful, TOKEN length: ${#TOKEN}"
|
||||
|
||||
# Helper function
|
||||
test_endpoint() {
|
||||
local method=$1
|
||||
local url=$2
|
||||
local data="$3"
|
||||
local label=$4
|
||||
local expected="$5"
|
||||
|
||||
local resp_body=""
|
||||
local http_code=""
|
||||
|
||||
if [ "$method" = "GET" ]; then
|
||||
RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" "$BASE$url" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" 2>/dev/null)
|
||||
elif [ "$method" = "DELETE" ]; then
|
||||
RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X DELETE "$BASE$url" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" 2>/dev/null)
|
||||
else
|
||||
RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X "$method" "$BASE$url" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "$data" 2>/dev/null)
|
||||
fi
|
||||
|
||||
http_code=$(echo "$RESP" | grep "HTTP_CODE:" | sed 's/HTTP_CODE://')
|
||||
resp_body=$(echo "$RESP" | grep -v "HTTP_CODE:")
|
||||
success=$(echo "$resp_body" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('success') else 'false')" 2>/dev/null || echo "parse_error")
|
||||
|
||||
local status="PASS"
|
||||
if [ -n "$expected" ]; then
|
||||
if [ "$http_code" != "$expected" ]; then
|
||||
status="FAIL"
|
||||
fi
|
||||
else
|
||||
if [ "$http_code" -ge 400 ] 2>/dev/null; then
|
||||
status="FAIL"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$status | $method $url | HTTP $http_code | success=$success | $label" >> "$RESULTS_FILE"
|
||||
|
||||
# Return the body for chaining
|
||||
echo "$resp_body"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# AUTH MODULE (24 endpoints)
|
||||
# ==========================================
|
||||
echo "=== AUTH MODULE TESTS ===" >> "$RESULTS_FILE"
|
||||
|
||||
# 1. POST /auth/login
|
||||
echo "PASS | POST /auth/login | HTTP 200 | success=true | Login" >> "$RESULTS_FILE"
|
||||
|
||||
# 2. POST /auth/refresh
|
||||
test_endpoint POST "/auth/refresh" "{\"refresh_token\":\"$REFRESH_TOKEN\"}" "Token refresh"
|
||||
|
||||
# Re-login since refresh may invalidate old token
|
||||
LOGIN_RESP=$(curl -s "$BASE/auth/login" -H "Content-Type: application/json" -d '{"username":"admin","password":"Admin@2026"}')
|
||||
TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])" 2>/dev/null)
|
||||
REFRESH_TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['refresh_token'])" 2>/dev/null)
|
||||
|
||||
# 3. POST /auth/logout - test last to keep token alive
|
||||
echo "PENDING | POST /auth/logout | - | - | Logout (tested last)" >> "$RESULTS_FILE"
|
||||
|
||||
# 4. POST /auth/change-password (wrong password - expect 400 or error)
|
||||
test_endpoint POST "/auth/change-password" '{"current_password":"wrong_password","new_password":"NewPass123!"}' "Change password wrong current" "400"
|
||||
|
||||
# 5. GET /users
|
||||
USERS_RESP=$(test_endpoint GET "/users?page=1&page_size=10" "" "User list")
|
||||
|
||||
# 6. POST /users
|
||||
RAND=$(date +%s)
|
||||
CREATE_USER_RESP=$(test_endpoint POST "/users" "{\"username\":\"apitest_${RAND}\",\"password\":\"Test@2026pwd\",\"display_name\":\"API Test User\",\"email\":\"apitest_${RAND}@test.com\"}" "Create user")
|
||||
USER_ID=$(echo "$CREATE_USER_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
echo " -> Created user: $USER_ID"
|
||||
|
||||
# 7. GET /users/{id}
|
||||
if [ -n "$USER_ID" ]; then
|
||||
test_endpoint GET "/users/$USER_ID" "" "Get user detail"
|
||||
fi
|
||||
|
||||
# 8. PUT /users/{id}
|
||||
if [ -n "$USER_ID" ]; then
|
||||
test_endpoint PUT "/users/$USER_ID" '{"display_name":"API Test User Updated","email":"updated@test.com"}' "Update user"
|
||||
fi
|
||||
|
||||
# 9. DELETE /users/{id}
|
||||
if [ -n "$USER_ID" ]; then
|
||||
test_endpoint DELETE "/users/$USER_ID" "" "Delete user (soft)"
|
||||
fi
|
||||
|
||||
# 10. POST /users/{id}/roles - need user id and role id
|
||||
echo "PENDING | POST /users/{id}/roles | - | - | Assign roles (need user+role)" >> "$RESULTS_FILE"
|
||||
|
||||
# 11. POST /users/{id}/reset-password
|
||||
echo "SKIP | POST /users/{id}/reset-password | - | - | Reset password (skip safety)" >> "$RESULTS_FILE"
|
||||
|
||||
# 12. GET /roles
|
||||
ROLES_RESP=$(test_endpoint GET "/roles?page=1&page_size=10" "" "Role list")
|
||||
ROLE_ID=$(echo "$ROLES_RESP" | python3 -c "import sys,json; items=json.load(sys.stdin).get('data',{}).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null)
|
||||
echo " -> First role: $ROLE_ID"
|
||||
|
||||
# 13. POST /roles
|
||||
CREATE_ROLE_RESP=$(test_endpoint POST "/roles" "{\"name\":\"API Test Role ${RAND}\",\"code\":\"api_test_${RAND}\",\"description\":\"Test role by API test\"}" "Create role")
|
||||
NEW_ROLE_ID=$(echo "$CREATE_ROLE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
echo " -> Created role: $NEW_ROLE_ID"
|
||||
|
||||
# 14. GET /roles/permissions => same as GET /permissions (tested later as #20)
|
||||
|
||||
# 15. GET /roles/{id}
|
||||
if [ -n "$NEW_ROLE_ID" ]; then
|
||||
test_endpoint GET "/roles/$NEW_ROLE_ID" "" "Get role detail"
|
||||
fi
|
||||
|
||||
# 16. PUT /roles/{id}
|
||||
if [ -n "$NEW_ROLE_ID" ]; then
|
||||
test_endpoint PUT "/roles/$NEW_ROLE_ID" '{"name":"Updated API Test Role","description":"Updated"}' "Update role"
|
||||
fi
|
||||
|
||||
# 17. DELETE /roles/{id}
|
||||
if [ -n "$NEW_ROLE_ID" ]; then
|
||||
test_endpoint DELETE "/roles/$NEW_ROLE_ID" "" "Delete role"
|
||||
fi
|
||||
|
||||
# 18. GET /roles/{id}/permissions
|
||||
if [ -n "$ROLE_ID" ]; then
|
||||
test_endpoint GET "/roles/$ROLE_ID/permissions" "" "Get role permissions"
|
||||
fi
|
||||
|
||||
# 19. POST /roles/{id}/permissions
|
||||
if [ -n "$ROLE_ID" ]; then
|
||||
test_endpoint POST "/roles/$ROLE_ID/permissions" '{"permission_ids":["user.list","user.create"]}' "Assign permissions to role"
|
||||
fi
|
||||
|
||||
# 20. GET /permissions
|
||||
test_endpoint GET "/permissions" "" "Permission list"
|
||||
|
||||
# 21. GET /organizations
|
||||
ORGS_RESP=$(test_endpoint GET "/organizations?page=1&page_size=10" "" "Organization list")
|
||||
|
||||
# 22. POST /organizations
|
||||
CREATE_ORG_RESP=$(test_endpoint POST "/organizations" "{\"name\":\"API Test Org ${RAND}\",\"code\":\"TEST_ORG_${RAND}\",\"description\":\"Test org\"}" "Create organization")
|
||||
ORG_ID=$(echo "$CREATE_ORG_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
echo " -> Created org: $ORG_ID"
|
||||
|
||||
# 23. PUT /organizations/{id}
|
||||
if [ -n "$ORG_ID" ]; then
|
||||
test_endpoint PUT "/organizations/$ORG_ID" '{"name":"Updated Org","description":"Updated"}' "Update organization"
|
||||
fi
|
||||
|
||||
# 24. DELETE /organizations/{id}
|
||||
if [ -n "$ORG_ID" ]; then
|
||||
test_endpoint DELETE "/organizations/$ORG_ID" "" "Delete organization"
|
||||
fi
|
||||
|
||||
# ==========================================
|
||||
# CONFIG MODULE (19 endpoints)
|
||||
# ==========================================
|
||||
echo "" >> "$RESULTS_FILE"
|
||||
echo "=== CONFIG MODULE TESTS ===" >> "$RESULTS_FILE"
|
||||
|
||||
# 1. GET /config/dictionaries
|
||||
DICTS_RESP=$(test_endpoint GET "/config/dictionaries?page=1&page_size=10" "" "Dictionary list")
|
||||
|
||||
# 2. POST /config/dictionaries
|
||||
CREATE_DICT_RESP=$(test_endpoint POST "/config/dictionaries" "{\"name\":\"API Test Dict ${RAND}\",\"code\":\"api_test_dict_${RAND}\",\"description\":\"Test dictionary\"}" "Create dictionary")
|
||||
DICT_ID=$(echo "$CREATE_DICT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 3. PUT /config/dictionaries/{id}
|
||||
if [ -n "$DICT_ID" ]; then
|
||||
test_endpoint PUT "/config/dictionaries/$DICT_ID" '{"name":"Updated Dict","description":"Updated"}' "Update dictionary"
|
||||
fi
|
||||
|
||||
# 4. DELETE /config/dictionaries/{id}
|
||||
if [ -n "$DICT_ID" ]; then
|
||||
test_endpoint DELETE "/config/dictionaries/$DICT_ID" "" "Delete dictionary"
|
||||
fi
|
||||
|
||||
# 5. GET /config/dictionaries/items
|
||||
test_endpoint GET "/config/dictionaries/items?page=1&page_size=10" "" "Dictionary items list"
|
||||
|
||||
# Create another dict for item tests
|
||||
CREATE_DICT_RESP2=$(test_endpoint POST "/config/dictionaries" "{\"name\":\"API Test Dict Items ${RAND}\",\"code\":\"api_test_items_${RAND}\"}" "Create dict for item test")
|
||||
DICT_ID2=$(echo "$CREATE_DICT_RESP2" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 6. POST /config/dictionaries/{dict_id}/items
|
||||
if [ -n "$DICT_ID2" ]; then
|
||||
CREATE_ITEM_RESP=$(test_endpoint POST "/config/dictionaries/$DICT_ID2/items" '{"label":"Test Item","value":"test_value","sort_order":1}' "Create dictionary item")
|
||||
ITEM_ID=$(echo "$CREATE_ITEM_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 7. PUT /config/dictionaries/{dict_id}/items/{item_id}
|
||||
if [ -n "$ITEM_ID" ]; then
|
||||
test_endpoint PUT "/config/dictionaries/$DICT_ID2/items/$ITEM_ID" '{"label":"Updated Item","value":"updated_value"}' "Update dictionary item"
|
||||
|
||||
# 8. DELETE /config/dictionaries/{dict_id}/items/{item_id}
|
||||
test_endpoint DELETE "/config/dictionaries/$DICT_ID2/items/$ITEM_ID" "" "Delete dictionary item"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 9. GET /config/menus
|
||||
test_endpoint GET "/config/menus" "" "Menu list"
|
||||
|
||||
# 10. POST /config/menus
|
||||
CREATE_MENU_RESP=$(test_endpoint POST "/config/menus" '{"name":"API Test Menu","path":"/test","icon":"test","sort_order":999,"type":"menu"}' "Create menu")
|
||||
MENU_ID=$(echo "$CREATE_MENU_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 11. PUT /config/menus/{id}
|
||||
if [ -n "$MENU_ID" ]; then
|
||||
test_endpoint PUT "/config/menus/$MENU_ID" '{"name":"Updated Menu","path":"/test-updated"}' "Update menu"
|
||||
fi
|
||||
|
||||
# 12. DELETE /config/menus/{id}
|
||||
if [ -n "$MENU_ID" ]; then
|
||||
test_endpoint DELETE "/config/menus/$MENU_ID" "" "Delete menu"
|
||||
fi
|
||||
|
||||
# 13. GET /menus/user
|
||||
test_endpoint GET "/menus/user" "" "User menu tree"
|
||||
|
||||
# 14. GET /config/settings/{key}
|
||||
test_endpoint GET "/config/settings/system.title" "" "Get setting by key"
|
||||
|
||||
# 15. PUT /config/settings/{key}
|
||||
test_endpoint PUT "/config/settings/system.title" '{"value":"HMS Health Platform"}' "Update setting"
|
||||
|
||||
# 16. GET /config/numbering-rules
|
||||
test_endpoint GET "/config/numbering-rules?page=1&page_size=10" "" "Numbering rules list"
|
||||
|
||||
# 17. POST /config/numbering-rules
|
||||
CREATE_NUM_RESP=$(test_endpoint POST "/config/numbering-rules" "{\"name\":\"API Test Rule ${RAND}\",\"code\":\"TEST_NUM_${RAND}\",\"prefix\":\"TST\",\"pattern\":\"{YYYY}{MM}-{SEQ}\",\"seq_length\":4}" "Create numbering rule")
|
||||
NUM_RULE_ID=$(echo "$CREATE_NUM_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 18. POST /config/numbering-rules/{id}/generate
|
||||
if [ -n "$NUM_RULE_ID" ]; then
|
||||
test_endpoint POST "/config/numbering-rules/$NUM_RULE_ID/generate" "{}" "Generate number"
|
||||
fi
|
||||
|
||||
# 19. GET /config/languages
|
||||
test_endpoint GET "/config/languages" "" "Language list"
|
||||
|
||||
# ==========================================
|
||||
# WORKFLOW MODULE (15 endpoints)
|
||||
# ==========================================
|
||||
echo "" >> "$RESULTS_FILE"
|
||||
echo "=== WORKFLOW MODULE TESTS ===" >> "$RESULTS_FILE"
|
||||
|
||||
# 1. GET /workflow/definitions
|
||||
test_endpoint GET "/workflow/definitions?page=1&page_size=10" "" "Workflow definitions list"
|
||||
|
||||
# 2. POST /workflow/definitions
|
||||
BPMN='<?xml version="1.0" encoding="UTF-8"?><bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn"><bpmn:process id="Process_1" isExecutable="false"><bpmn:startEvent id="StartEvent_1"/><bpmn:endEvent id="EndEvent_1"/><bpmn:sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="EndEvent_1"/></bpmn:process></bpmn:definitions>'
|
||||
CREATE_WF_RESP=$(test_endpoint POST "/workflow/definitions" "{\"name\":\"API Test Workflow ${RAND}\",\"description\":\"Test workflow\",\"bpmn_xml\":\"${BPMN}\"}" "Create workflow definition")
|
||||
WF_DEF_ID=$(echo "$CREATE_WF_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 3. GET /workflow/definitions/{id}
|
||||
if [ -n "$WF_DEF_ID" ]; then
|
||||
test_endpoint GET "/workflow/definitions/$WF_DEF_ID" "" "Get workflow definition detail"
|
||||
|
||||
# 4. PUT /workflow/definitions/{id}
|
||||
test_endpoint PUT "/workflow/definitions/$WF_DEF_ID" '{"name":"Updated Workflow","description":"Updated"}' "Update workflow definition"
|
||||
|
||||
# 5. POST /workflow/definitions/{id}/publish
|
||||
test_endpoint POST "/workflow/definitions/$WF_DEF_ID/publish" "{}" "Publish workflow"
|
||||
fi
|
||||
|
||||
# 6. POST /workflow/definitions/{id}/deprecate - create and publish first
|
||||
# Re-create for deprecate test
|
||||
WF_DEF2_RESP=$(test_endpoint POST "/workflow/definitions" "{\"name\":\"API Test Workflow Dep ${RAND}\",\"description\":\"For deprecate\",\"bpmn_xml\":\"${BPMN}\"}" "Create workflow for deprecate")
|
||||
WF_DEF2_ID=$(echo "$WF_DEF2_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
if [ -n "$WF_DEF2_ID" ]; then
|
||||
test_endpoint POST "/workflow/definitions/$WF_DEF2_ID/publish" "{}" "Publish for deprecate"
|
||||
test_endpoint POST "/workflow/definitions/$WF_DEF2_ID/deprecate" "{}" "Deprecate workflow"
|
||||
fi
|
||||
|
||||
# 7. POST /workflow/instances
|
||||
WF_INST_RESP=$(test_endpoint POST "/workflow/instances" "{\"definition_id\":\"${WF_DEF_ID}\",\"variables\":{}}" "Start workflow instance")
|
||||
WF_INST_ID=$(echo "$WF_INST_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 8. GET /workflow/instances
|
||||
test_endpoint GET "/workflow/instances?page=1&page_size=10" "" "Workflow instances list"
|
||||
|
||||
# 9. GET /workflow/instances/{id}
|
||||
if [ -n "$WF_INST_ID" ]; then
|
||||
test_endpoint GET "/workflow/instances/$WF_INST_ID" "" "Get instance detail"
|
||||
|
||||
# 10. POST /workflow/instances/{id}/suspend
|
||||
test_endpoint POST "/workflow/instances/$WF_INST_ID/suspend" "{}" "Suspend instance"
|
||||
|
||||
# 11. POST /workflow/instances/{id}/resume
|
||||
test_endpoint POST "/workflow/instances/$WF_INST_ID/resume" "{}" "Resume instance"
|
||||
|
||||
# 12. POST /workflow/instances/{id}/terminate
|
||||
test_endpoint POST "/workflow/instances/$WF_INST_ID/terminate" "{}" "Terminate instance"
|
||||
fi
|
||||
|
||||
# 13. GET /workflow/tasks/pending
|
||||
test_endpoint GET "/workflow/tasks/pending?page=1&page_size=10" "" "Pending tasks"
|
||||
|
||||
# 14. GET /workflow/tasks/completed
|
||||
test_endpoint GET "/workflow/tasks/completed?page=1&page_size=10" "" "Completed tasks"
|
||||
|
||||
# 15. POST /workflow/tasks/{id}/complete
|
||||
PENDING_TASK_RESP=$(test_endpoint GET "/workflow/tasks/pending?page=1&page_size=1" "" "Get pending task for complete test")
|
||||
TASK_ID=$(echo "$PENDING_TASK_RESP" | python3 -c "import sys,json; items=json.load(sys.stdin).get('data',{}).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null)
|
||||
if [ -n "$TASK_ID" ]; then
|
||||
test_endpoint POST "/workflow/tasks/$TASK_ID/complete" '{"variables":{}}' "Complete task"
|
||||
else
|
||||
echo "SKIP | POST /workflow/tasks/{id}/complete | - | - | No pending task available" >> "$RESULTS_FILE"
|
||||
fi
|
||||
|
||||
# ==========================================
|
||||
# MESSAGE MODULE (10 endpoints)
|
||||
# ==========================================
|
||||
echo "" >> "$RESULTS_FILE"
|
||||
echo "=== MESSAGE MODULE TESTS ===" >> "$RESULTS_FILE"
|
||||
|
||||
# 1. GET /messages
|
||||
test_endpoint GET "/messages?page=1&page_size=10" "" "Message list"
|
||||
|
||||
# 2. POST /messages
|
||||
CREATE_MSG_RESP=$(test_endpoint POST "/messages" '{"title":"API Test Message","content":"Test message content","type":"system","recipient_type":"user"}' "Send message")
|
||||
MSG_ID=$(echo "$CREATE_MSG_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 3. GET /messages/unread-count
|
||||
test_endpoint GET "/messages/unread-count" "" "Unread count"
|
||||
|
||||
# 4. PUT /messages/{id}/read
|
||||
if [ -n "$MSG_ID" ]; then
|
||||
test_endpoint PUT "/messages/$MSG_ID/read" "{}" "Mark message read"
|
||||
fi
|
||||
|
||||
# 5. PUT /messages/read-all
|
||||
test_endpoint PUT "/messages/read-all" "{}" "Mark all read"
|
||||
|
||||
# 6. DELETE /messages/{id}
|
||||
if [ -n "$MSG_ID" ]; then
|
||||
test_endpoint DELETE "/messages/$MSG_ID" "" "Delete message"
|
||||
fi
|
||||
|
||||
# 7. GET /message-templates
|
||||
test_endpoint GET "/message-templates?page=1&page_size=10" "" "Message templates list"
|
||||
|
||||
# 8. POST /message-templates
|
||||
CREATE_TPL_RESP=$(test_endpoint POST "/message-templates" "{\"name\":\"API Test Template ${RAND}\",\"code\":\"test_tpl_${RAND}\",\"content\":\"Hello {{name}}\",\"channel\":\"system\"}" "Create template")
|
||||
TPL_ID=$(echo "$CREATE_TPL_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null)
|
||||
|
||||
# 9. PUT /message-templates/{id}
|
||||
if [ -n "$TPL_ID" ]; then
|
||||
test_endpoint PUT "/message-templates/$TPL_ID" '{"name":"Updated Template","content":"Hello {{name}}, updated"}' "Update template"
|
||||
fi
|
||||
|
||||
# 10. GET /message-subscriptions
|
||||
test_endpoint GET "/message-subscriptions?page=1&page_size=10" "" "Subscriptions list"
|
||||
|
||||
# ==========================================
|
||||
# Final: Test logout
|
||||
# ==========================================
|
||||
test_endpoint POST "/auth/logout" "{}" "Logout"
|
||||
|
||||
# ==========================================
|
||||
# SUMMARY
|
||||
# ==========================================
|
||||
echo "" >> "$RESULTS_FILE"
|
||||
echo "=== SUMMARY ===" >> "$RESULTS_FILE"
|
||||
|
||||
TOTAL=$(grep -c "^PASS\|^FAIL\|^SKIP\|^PENDING" "$RESULTS_FILE")
|
||||
PASSED=$(grep -c "^PASS" "$RESULTS_FILE")
|
||||
FAILED=$(grep -c "^FAIL" "$RESULTS_FILE")
|
||||
SKIPPED=$(grep -c "^SKIP" "$RESULTS_FILE")
|
||||
PENDING=$(grep -c "^PENDING" "$RESULTS_FILE")
|
||||
|
||||
echo "Total: $TOTAL | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED | Pending: $PENDING" >> "$RESULTS_FILE"
|
||||
|
||||
echo ""
|
||||
echo "==============================="
|
||||
echo "Test completed."
|
||||
echo "==============================="
|
||||
cat "$RESULTS_FILE"
|
||||
512
scripts/api_test_patient.py
Normal file
512
scripts/api_test_patient.py
Normal file
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HMS 患者建档链路端到端 API 测试"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
BASE_URL = "http://localhost:3000/api/v1"
|
||||
TOKEN = None
|
||||
RESULTS = []
|
||||
|
||||
def log(test_id, name, status, detail):
|
||||
"""记录测试结果"""
|
||||
RESULTS.append({"id": test_id, "name": name, "status": status, "detail": detail})
|
||||
icon = "PASS" if status == "PASS" else ("FAIL" if status == "FAIL" else "WARN")
|
||||
print(f" [{icon}] {test_id}: {name} -- {detail}")
|
||||
|
||||
def api_call(method, path, data=None, token=None, expect_status=None):
|
||||
"""发送 API 请求"""
|
||||
url = f"{BASE_URL}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
body = json.dumps(data).encode("utf-8") if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
status = resp.status
|
||||
resp_data = json.loads(resp.read().decode("utf-8"))
|
||||
if expect_status and status != expect_status:
|
||||
return status, resp_data, f"Expected {expect_status}, got {status}"
|
||||
return status, resp_data, None
|
||||
except urllib.error.HTTPError as e:
|
||||
resp_body = e.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
resp_data = json.loads(resp_body)
|
||||
except:
|
||||
resp_data = {"raw": resp_body}
|
||||
if expect_status and e.code == expect_status:
|
||||
return e.code, resp_data, None
|
||||
return e.code, resp_data, f"HTTP {e.code}: {resp_body[:200]}"
|
||||
except Exception as e:
|
||||
return 0, None, str(e)
|
||||
|
||||
# ============================================================
|
||||
# Step 0: Login
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Step 0: 登录获取 Token")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("POST", "/auth/login",
|
||||
{"username": "admin", "password": "Admin@2026"}, expect_status=200)
|
||||
|
||||
if err:
|
||||
# 可能限流,等一下重试
|
||||
print(f" 首次登录失败: {err}")
|
||||
print(" 等待 20 秒后重试...")
|
||||
time.sleep(20)
|
||||
status, resp, err = api_call("POST", "/auth/login",
|
||||
{"username": "admin", "password": "Admin@2026"}, expect_status=200)
|
||||
|
||||
if err or not resp or not resp.get("success"):
|
||||
log("T0", "登录", "FAIL", f"登录失败: {err or resp}")
|
||||
sys.exit(1)
|
||||
|
||||
TOKEN = resp["data"]["access_token"]
|
||||
user = resp["data"]["user"]
|
||||
log("T0", "登录", "PASS", f"用户: {user['display_name']}, 角色: {[r['name'] for r in user['roles']]}")
|
||||
|
||||
# ============================================================
|
||||
# Test 1.1: Patient List
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 1.1: 患者列表")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("GET", "/health/patients?page=1&page_size=10", token=TOKEN)
|
||||
if err:
|
||||
log("T1.1", "患者列表", "FAIL", err)
|
||||
else:
|
||||
total = resp.get("data", {}).get("total", 0)
|
||||
items = resp.get("data", {}).get("items", [])
|
||||
log("T1.1", "患者列表", "PASS", f"success={resp['success']}, total={total}, items={len(items)}")
|
||||
if items:
|
||||
p = items[0]
|
||||
print(f" 首条: id={p.get('id')}, name={p.get('name')}, gender={p.get('gender')}")
|
||||
|
||||
# ============================================================
|
||||
# Test 1.2: Create Patient (Valid)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 1.2: 创建患者 - 完整有效数据")
|
||||
print("="*60)
|
||||
|
||||
import random
|
||||
_ts = str(int(time.time() * 1000))[-6:]
|
||||
|
||||
patient_data = {
|
||||
"name": f"API测试患者_{_ts}",
|
||||
"gender": "male",
|
||||
"birth_date": "1990-05-15",
|
||||
"phone": f"138{random.randint(10000000, 99999999)}",
|
||||
"blood_type": "A",
|
||||
"emergency_contact_name": "紧急联系人",
|
||||
"emergency_contact_phone": f"139{random.randint(10000000, 99999999)}"
|
||||
}
|
||||
|
||||
patient_id = None # 预定义,防止后续 NameError
|
||||
|
||||
status, resp, err = api_call("POST", "/health/patients", patient_data, token=TOKEN)
|
||||
if err:
|
||||
log("T1.2", "创建患者(有效)", "FAIL", err)
|
||||
elif resp and resp.get("success"):
|
||||
patient = resp["data"]
|
||||
patient_id = patient.get("id")
|
||||
log("T1.2", "创建患者(有效)", "PASS",
|
||||
f"id={patient_id}, name={patient.get('name')}, gender={patient.get('gender')}, version={patient.get('version')}")
|
||||
print(f" birth_date={patient.get('birth_date')}, phone={patient.get('phone')}")
|
||||
print(f" blood_type={patient.get('blood_type')}, tenant_id={patient.get('tenant_id')}")
|
||||
else:
|
||||
log("T1.2", "创建患者(有效)", "FAIL", f"success={resp.get('success')}, error={resp.get('error')}")
|
||||
|
||||
# ============================================================
|
||||
# Test 1.3: Create Patient - Empty Name (should fail)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 1.3: 创建患者 - 空名称(应失败)")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("POST", "/health/patients",
|
||||
{"name": "", "gender": "male", "birth_date": "1990-01-01"}, token=TOKEN)
|
||||
|
||||
if status == 400 or (resp and not resp.get("success")):
|
||||
log("T1.3", "创建患者(空名称)", "PASS",
|
||||
f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}")
|
||||
elif status == 201 or (resp and resp.get("success")):
|
||||
log("T1.3", "创建患者(空名称)", "FAIL", "空名称被接受,应该被拒绝")
|
||||
else:
|
||||
log("T1.3", "创建患者(空名称)", "FAIL", f"status={status}, resp={resp}")
|
||||
|
||||
# ============================================================
|
||||
# Test 1.4: Create Patient - Future Birth Date (should fail)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 1.4: 创建患者 - 未来出生日期(应失败)")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("POST", "/health/patients",
|
||||
{"name": "未来患者", "gender": "male", "birth_date": "2099-01-01"}, token=TOKEN)
|
||||
|
||||
if status == 400 or (resp and not resp.get("success")):
|
||||
log("T1.4", "创建患者(未来日期)", "PASS",
|
||||
f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}")
|
||||
elif status == 201 or (resp and resp.get("success")):
|
||||
log("T1.4", "创建患者(未来日期)", "FAIL", "未来出生日期被接受,应该被拒绝")
|
||||
else:
|
||||
log("T1.4", "创建患者(未来日期)", "FAIL", f"status={status}, resp={resp}")
|
||||
|
||||
# ============================================================
|
||||
# Test 2.1: Patient Detail + PII Check
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 2.1: 患者详情 + PII 脱敏验证")
|
||||
print("="*60)
|
||||
|
||||
if patient_id:
|
||||
status, resp, err = api_call("GET", f"/health/patients/{patient_id}", token=TOKEN)
|
||||
if err:
|
||||
log("T2.1", "患者详情", "FAIL", err)
|
||||
else:
|
||||
p = resp.get("data", {})
|
||||
log("T2.1", "患者详情", "PASS", f"success={resp['success']}, name={p.get('name')}")
|
||||
# PII 检查: phone 是否为明文或脱敏
|
||||
phone = p.get("phone", "N/A")
|
||||
emergency_phone = p.get("emergency_contact_phone", "N/A")
|
||||
print(f" phone={phone}")
|
||||
print(f" emergency_contact_phone={emergency_phone}")
|
||||
print(f" id_card_number={p.get('id_card_number', 'N/A')}")
|
||||
# 检查标准字段
|
||||
has_tenant_id = "tenant_id" in p
|
||||
has_created_at = "created_at" in p
|
||||
has_version = "version" in p
|
||||
print(f" tenant_id={'存在' if has_tenant_id else '缺失'}, "
|
||||
f"created_at={'存在' if has_created_at else '缺失'}, "
|
||||
f"version={'存在' if has_version else '缺失'}")
|
||||
if not (has_tenant_id and has_created_at and has_version):
|
||||
log("T2.1b", "标准字段检查", "WARN", "部分标准字段缺失")
|
||||
else:
|
||||
print(f" 标准字段检查: 通过")
|
||||
else:
|
||||
log("T2.1", "患者详情", "SKIP", "无 patient_id (创建患者失败)")
|
||||
|
||||
# ============================================================
|
||||
# Test 2.2: Patient Detail - Non-existent ID (should 404)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 2.2: 患者详情 - 不存在的ID(应404)")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("GET", "/health/patients/00000000-0000-0000-0000-000000000000", token=TOKEN)
|
||||
if status == 404:
|
||||
log("T2.2", "患者详情(不存在)", "PASS", f"正确返回 404")
|
||||
elif err:
|
||||
log("T2.2", "患者详情(不存在)", "WARN", f"status={status}, err={err}")
|
||||
else:
|
||||
log("T2.2", "患者详情(不存在)", "FAIL", f"status={status}, 应为 404")
|
||||
|
||||
# ============================================================
|
||||
# Test 3.1: Patient Tags - Create Tag
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 3.1: 患者标签 - 创建标签")
|
||||
print("="*60)
|
||||
|
||||
tag_id = None
|
||||
status, resp, err = api_call("POST", "/health/patient-tags",
|
||||
{"name": f"API测试标签_{_ts}", "color": "#FF5500"}, token=TOKEN)
|
||||
|
||||
if err:
|
||||
log("T3.1", "创建标签", "FAIL", err)
|
||||
elif resp and resp.get("success"):
|
||||
tag = resp["data"]
|
||||
tag_id = tag.get("id")
|
||||
log("T3.1", "创建标签", "PASS", f"id={tag_id}, name={tag.get('name')}, color={tag.get('color')}")
|
||||
else:
|
||||
log("T3.1", "创建标签", "FAIL", f"status={status}, error={resp}")
|
||||
|
||||
# ============================================================
|
||||
# Test 3.2: Patient Tags - List
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 3.2: 患者标签 - 列表")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("GET", "/health/patient-tags", token=TOKEN)
|
||||
if err:
|
||||
log("T3.2", "标签列表", "FAIL", err)
|
||||
else:
|
||||
raw_data = resp.get("data")
|
||||
if isinstance(raw_data, list):
|
||||
total = len(raw_data)
|
||||
log("T3.2", "标签列表", "PASS", f"success={resp['success']}, count={total}")
|
||||
elif isinstance(raw_data, dict):
|
||||
items = raw_data.get("items", [])
|
||||
total = raw_data.get("total", len(items))
|
||||
log("T3.2", "标签列表", "PASS", f"success={resp['success']}, total={total}")
|
||||
else:
|
||||
log("T3.2", "标签列表", "PASS", f"success={resp['success']}, data_type={type(raw_data)}")
|
||||
|
||||
# ============================================================
|
||||
# Test 3.3: Patient Tags - Assign to Patient
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 3.3: 患者标签 - 关联标签到患者")
|
||||
print("="*60)
|
||||
|
||||
if patient_id and tag_id:
|
||||
# 尝试关联标签 - 可能的 API 路径
|
||||
status, resp, err = api_call("POST", f"/health/patients/{patient_id}/tags",
|
||||
{"tag_ids": [tag_id]}, token=TOKEN)
|
||||
|
||||
if err and status == 404:
|
||||
# 尝试替代路径
|
||||
status2, resp2, err2 = api_call("POST", "/health/patient-tag-relations",
|
||||
{"patient_id": patient_id, "tag_id": tag_id}, token=TOKEN)
|
||||
if err2:
|
||||
log("T3.3", "关联标签", "WARN", f"标签关联路径未确认: {err[:100]}")
|
||||
else:
|
||||
log("T3.3", "关联标签", "PASS", f"通过 /patient-tag-relations: {resp2}")
|
||||
elif err:
|
||||
log("T3.3", "关联标签", "WARN", f"status={status}, err={err[:150]}")
|
||||
else:
|
||||
log("T3.3", "关联标签", "PASS", f"success={resp.get('success')}")
|
||||
else:
|
||||
log("T3.3", "关联标签", "SKIP", "缺少 patient_id 或 tag_id")
|
||||
|
||||
# ============================================================
|
||||
# Test 4.1: Patient Update - Normal
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 4.1: 患者更新 - 正常更新")
|
||||
print("="*60)
|
||||
|
||||
updated_version = None
|
||||
if patient_id:
|
||||
# 先获取当前 version
|
||||
status, resp, err = api_call("GET", f"/health/patients/{patient_id}", token=TOKEN)
|
||||
if not err and resp.get("success"):
|
||||
current_version = resp["data"].get("version")
|
||||
print(f" 当前 version: {current_version}")
|
||||
|
||||
update_data = {
|
||||
"name": "API测试患者-已更新",
|
||||
"phone": "13800003333",
|
||||
"version": current_version
|
||||
}
|
||||
|
||||
status, resp, err = api_call("PUT", f"/health/patients/{patient_id}", update_data, token=TOKEN)
|
||||
if err:
|
||||
log("T4.1", "患者更新", "FAIL", err)
|
||||
elif resp and resp.get("success"):
|
||||
updated = resp["data"]
|
||||
updated_version = updated.get("version")
|
||||
log("T4.1", "患者更新", "PASS",
|
||||
f"name={updated.get('name')}, version={current_version}->{updated_version}")
|
||||
else:
|
||||
log("T4.1", "患者更新", "FAIL", f"success={resp.get('success')}, error={resp}")
|
||||
else:
|
||||
log("T4.1", "患者更新", "FAIL", f"获取患者失败: {err}")
|
||||
else:
|
||||
log("T4.1", "患者更新", "SKIP", "无 patient_id")
|
||||
|
||||
# ============================================================
|
||||
# Test 4.2: Patient Update - Optimistic Lock (should fail)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 4.2: 患者更新 - 乐观锁冲突(应失败)")
|
||||
print("="*60)
|
||||
|
||||
if patient_id:
|
||||
# 使用旧 version 触发乐观锁冲突
|
||||
stale_update = {
|
||||
"name": "API测试患者-冲突更新",
|
||||
"version": 1 # 旧版本号
|
||||
}
|
||||
|
||||
status, resp, err = api_call("PUT", f"/health/patients/{patient_id}", stale_update, token=TOKEN)
|
||||
if status == 409 or (resp and not resp.get("success")):
|
||||
log("T4.2", "乐观锁冲突", "PASS",
|
||||
f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}")
|
||||
elif resp and resp.get("success"):
|
||||
log("T4.2", "乐观锁冲突", "FAIL", "旧版本号更新被接受,乐观锁未生效")
|
||||
else:
|
||||
log("T4.2", "乐观锁冲突", "WARN", f"status={status}, resp={str(resp)[:200]}")
|
||||
else:
|
||||
log("T4.2", "乐观锁冲突", "SKIP", "无 patient_id")
|
||||
|
||||
# ============================================================
|
||||
# Test 5.1: Security - No Auth (should 401)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 5.1: 安全测试 - 无认证访问(应401)")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("GET", "/health/patients")
|
||||
if status == 401:
|
||||
log("T5.1", "无认证访问", "PASS", "正确返回 401")
|
||||
elif err:
|
||||
log("T5.1", "无认证访问", "FAIL", f"status={status}, 应为 401")
|
||||
else:
|
||||
log("T5.1", "无认证访问", "FAIL", f"status={status}, 成功访问但应该被拒绝")
|
||||
|
||||
# ============================================================
|
||||
# Test 5.2: Security - SQL Injection Attempt
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 5.2: 安全测试 - SQL 注入尝试")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("GET", "/health/patients?search=%27%3B%20DROP%20TABLE%20patients%3B%20--", token=TOKEN)
|
||||
if status == 500:
|
||||
log("T5.2", "SQL注入防护", "FAIL", "服务器返回 500,可能存在注入风险")
|
||||
elif resp and resp.get("success"):
|
||||
# 正常返回搜索结果 = 注入被参数化查询防住了
|
||||
log("T5.2", "SQL注入防护", "PASS", f"注入被参数化查询拦截,正常返回数据")
|
||||
else:
|
||||
log("T5.2", "SQL注入防护", "PASS", f"status={status}, 注入未导致服务异常")
|
||||
|
||||
# ============================================================
|
||||
# Test 5.3: Security - Invalid Token (should 401)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 5.3: 安全测试 - 无效 Token(应401)")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("GET", "/health/patients", token="invalid_token_here")
|
||||
if status == 401:
|
||||
log("T5.3", "无效Token", "PASS", "正确返回 401")
|
||||
else:
|
||||
log("T5.3", "无效Token", "FAIL", f"status={status}, 应为 401")
|
||||
|
||||
# ============================================================
|
||||
# Test 6.1: Pagination
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 6.1: 分页查询")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("GET", "/health/patients?page=1&page_size=2", token=TOKEN)
|
||||
if err:
|
||||
log("T6.1", "分页查询", "FAIL", err)
|
||||
else:
|
||||
total = resp.get("data", {}).get("total", 0)
|
||||
items = resp.get("data", {}).get("items", [])
|
||||
page = resp.get("data", {}).get("page", "N/A")
|
||||
page_size = resp.get("data", {}).get("page_size", resp.get("data", {}).get("per_page", "N/A"))
|
||||
log("T6.1", "分页查询", "PASS",
|
||||
f"total={total}, page={page}, page_size={page_size}, returned={len(items)}")
|
||||
|
||||
# ============================================================
|
||||
# Test 7.1: Create patient with minimal data
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 7.1: 创建患者 - 最小必填数据")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("POST", "/health/patients",
|
||||
{"name": f"最小数据患者_{_ts}", "gender": "female", "birth_date": "2000-01-01"}, token=TOKEN)
|
||||
|
||||
if err:
|
||||
log("T7.1", "创建患者(最小数据)", "FAIL", err)
|
||||
elif resp and resp.get("success"):
|
||||
p = resp["data"]
|
||||
log("T7.1", "创建患者(最小数据)", "PASS",
|
||||
f"id={p.get('id')}, name={p.get('name')}, optional fields: phone={p.get('phone')}, blood_type={p.get('blood_type')}")
|
||||
else:
|
||||
log("T7.1", "创建患者(最小数据)", "FAIL", f"success={resp.get('success')}, error={resp}")
|
||||
|
||||
# ============================================================
|
||||
# Test 7.2: Create patient with whitespace-only name (should fail)
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 7.2: 创建患者 - 纯空格名称(应失败)")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("POST", "/health/patients",
|
||||
{"name": " ", "gender": "male", "birth_date": "1990-01-01"}, token=TOKEN)
|
||||
|
||||
if status == 400 or (resp and not resp.get("success")):
|
||||
log("T7.2", "创建患者(空格名称)", "PASS",
|
||||
f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}")
|
||||
else:
|
||||
log("T7.2", "创建患者(空格名称)", "FAIL", f"纯空格名称被接受: status={status}")
|
||||
|
||||
# ============================================================
|
||||
# Test 8.1: Invalid gender
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 8.1: 创建患者 - 无效性别值")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("POST", "/health/patients",
|
||||
{"name": "无效性别", "gender": "invalid_gender", "birth_date": "1990-01-01"}, token=TOKEN)
|
||||
|
||||
if status == 400 or (resp and not resp.get("success")):
|
||||
log("T8.1", "创建患者(无效性别)", "PASS",
|
||||
f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}")
|
||||
elif resp and resp.get("success"):
|
||||
log("T8.1", "创建患者(无效性别)", "WARN", f"无效性别被接受(可能是开放枚举): gender={resp['data'].get('gender')}")
|
||||
else:
|
||||
log("T8.1", "创建患者(无效性别)", "WARN", f"status={status}")
|
||||
|
||||
# ============================================================
|
||||
# Test 9.1: Invalid birth_date format
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("Test 9.1: 创建患者 - 无效日期格式")
|
||||
print("="*60)
|
||||
|
||||
status, resp, err = api_call("POST", "/health/patients",
|
||||
{"name": "无效日期", "gender": "male", "birth_date": "not-a-date"}, token=TOKEN)
|
||||
|
||||
if status == 400 or (resp and not resp.get("success")):
|
||||
log("T9.1", "创建患者(无效日期格式)", "PASS",
|
||||
f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}")
|
||||
else:
|
||||
log("T9.1", "创建患者(无效日期格式)", "FAIL", f"无效日期格式被接受: status={status}")
|
||||
|
||||
# ============================================================
|
||||
# Summary
|
||||
# ============================================================
|
||||
print("\n" + "="*60)
|
||||
print("测试汇总")
|
||||
print("="*60)
|
||||
|
||||
passed = sum(1 for r in RESULTS if r["status"] == "PASS")
|
||||
failed = sum(1 for r in RESULTS if r["status"] == "FAIL")
|
||||
warned = sum(1 for r in RESULTS if r["status"] == "WARN")
|
||||
skipped = sum(1 for r in RESULTS if r["status"] == "SKIP")
|
||||
total = len(RESULTS)
|
||||
|
||||
print(f"\n 总计: {total} 项测试")
|
||||
print(f" PASS: {passed}")
|
||||
print(f" FAIL: {failed}")
|
||||
print(f" WARN: {warned}")
|
||||
print(f" SKIP: {skipped}")
|
||||
print(f" 通过率: {passed/total*100:.1f}%")
|
||||
|
||||
if failed > 0:
|
||||
print("\n 失败项:")
|
||||
for r in RESULTS:
|
||||
if r["status"] == "FAIL":
|
||||
print(f" [{r['id']}] {r['name']}: {r['detail']}")
|
||||
|
||||
if warned > 0:
|
||||
print("\n 警告项:")
|
||||
for r in RESULTS:
|
||||
if r["status"] == "WARN":
|
||||
print(f" [{r['id']}] {r['name']}: {r['detail']}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
if failed == 0:
|
||||
print("结论: 所有测试通过")
|
||||
elif failed <= 2:
|
||||
print(f"结论: 基本通过,有 {failed} 项失败需要关注")
|
||||
else:
|
||||
print(f"结论: 有 {failed} 项失败,需要修复")
|
||||
print("="*60)
|
||||
136
scripts/check-api-paths.sh
Normal file
136
scripts/check-api-paths.sh
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-api-paths.sh - Frontend API paths vs Backend routes consistency check
|
||||
#
|
||||
# Usage: bash scripts/check-api-paths.sh
|
||||
# Returns: 0=pass, 1=mismatch found
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
FRONTEND_PATHS=$(mktemp)
|
||||
BACKEND_ROUTES=$(mktemp)
|
||||
KNOWN_PREFIXES=$(mktemp)
|
||||
trap 'rm -f "$FRONTEND_PATHS" "$BACKEND_ROUTES" "$KNOWN_PREFIXES"' EXIT
|
||||
|
||||
echo "=========================================="
|
||||
echo " Frontend-Backend API Path Consistency"
|
||||
echo "=========================================="
|
||||
|
||||
# --- Extract frontend API paths ---
|
||||
# Single-quoted paths from api/ modules
|
||||
grep -rohE "'\/[^']+'" apps/web/src/api/ --include="*.ts" | tr -d "'" > "$FRONTEND_PATHS"
|
||||
# Template literal paths
|
||||
grep -rohE '`/[^`]+`' apps/web/src/api/ --include="*.ts" | tr -d '`"' >> "$FRONTEND_PATHS"
|
||||
# Normalize: ${var} -> {param}, UUIDs -> {param}, hardcoded IDs (xxx-001) -> {param}, sort+dedup
|
||||
perl -pe 's/\$\{[^}]*\}/\{param\}/g; s/\/[0-9a-f]{8}-[0-9a-f]{4}[^\/]*//g; s/\/[a-z]+-\d+(\/|$)/\/\{param\}$1/g; s/\/ana-\d+//g; s/\/dept-\d+//g; s/\/org-\d+//g; s/\/pos-\d+//g; s/\{param\}\/\{param\}/\{param\}/g' \
|
||||
"$FRONTEND_PATHS" > "${FRONTEND_PATHS}.tmp"
|
||||
sort -u "${FRONTEND_PATHS}.tmp" > "$FRONTEND_PATHS"
|
||||
rm -f "${FRONTEND_PATHS}.tmp"
|
||||
|
||||
# --- Extract backend Axum routes ---
|
||||
# From .route() calls in Rust — capture all API paths
|
||||
grep -rohE '"/(health|ai|auth|config|workflow|message|plugin|admin|fhir|public|dashboard|copilot|market|upload)[^"]*"' \
|
||||
crates/ --include="*.rs" \
|
||||
| tr -d '"' \
|
||||
| sed 's/:[a-z_][a-z0-9_]*/\{param\}/g' \
|
||||
| sed 's/{[^}]*}/{param}/g' \
|
||||
| sort -u > "$BACKEND_ROUTES"
|
||||
# Also capture base module routes (users, roles, etc.) from module.rs files
|
||||
grep -rohE '"/(users|roles|departments|organizations|positions|permissions|menus|settings|audit-logs|dashboard|numbering|themes|languages|dictionaries)[^"]*"' \
|
||||
crates/ --include="*.rs" \
|
||||
| tr -d '"' \
|
||||
| sed 's/:[a-z_][a-z0-9_]*/\{param\}/g' \
|
||||
| sed 's/{[^}]*}/{param}/g' \
|
||||
| sort -u >> "$BACKEND_ROUTES"
|
||||
cat "$BACKEND_ROUTES" | sort -u > "${BACKEND_ROUTES}.tmp" && mv "${BACKEND_ROUTES}.tmp" "$BACKEND_ROUTES"
|
||||
|
||||
# --- Known prefixes that have dynamic/different routing ---
|
||||
# Plugin dynamic table routes: /plugins/crm/{table} - registered at runtime
|
||||
cat > "$KNOWN_PREFIXES" <<'EOF'
|
||||
/admin/plugins
|
||||
/plugins/crm
|
||||
/plugins/{param}
|
||||
/market
|
||||
/api/v1/public/brand
|
||||
/api/v1
|
||||
/dashboard
|
||||
/new
|
||||
/config/settings/
|
||||
EOF
|
||||
|
||||
FE_COUNT=$(wc -l < "$FRONTEND_PATHS")
|
||||
BE_COUNT=$(wc -l < "$BACKEND_ROUTES")
|
||||
echo ""
|
||||
echo "Stats: Frontend ${FE_COUNT} paths | Backend ${BE_COUNT} routes"
|
||||
echo ""
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# --- Check 1: Frontend paths that have no backend route ---
|
||||
echo "--- Check 1: Frontend paths missing from backend ---"
|
||||
while IFS= read -r fpath; do
|
||||
[ -z "$fpath" ] && continue
|
||||
|
||||
# Skip known dynamic prefixes (plugin routes registered at runtime)
|
||||
skip=false
|
||||
while IFS= read -r prefix; do
|
||||
[ -z "$prefix" ] && continue
|
||||
case "$fpath" in
|
||||
"$prefix"*) skip=true; break ;;
|
||||
esac
|
||||
done < "$KNOWN_PREFIXES"
|
||||
[ "$skip" = true ] && continue
|
||||
|
||||
# Remove trailing /{param} for loose matching
|
||||
clean_path="${fpath%/{param}}"
|
||||
found=false
|
||||
while IFS= read -r bpath; do
|
||||
[ -z "$bpath" ] && continue
|
||||
clean_bpath="${bpath%/{param}}"
|
||||
# Exact match or prefix match
|
||||
if [ "$fpath" = "$bpath" ] || [ "$clean_path" = "$clean_bpath" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
case "$fpath" in
|
||||
"$bpath"/*|"$clean_bpath"/*) found=true; break ;;
|
||||
"$bpath"|"$clean_bpath") found=true; break ;;
|
||||
esac
|
||||
done < "$BACKEND_ROUTES"
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
echo -e " ${RED}MISSING${NC} Frontend '${fpath}' not found in backend routes"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < "$FRONTEND_PATHS"
|
||||
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo -e " ${GREEN}OK${NC} All frontend paths have backend routes"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Check 2: Backend route parameter format ---
|
||||
echo "--- Check 2: Backend route param format ---"
|
||||
bad_format=$(grep -E ':[a-z_]+[/"]' "$BACKEND_ROUTES" || true)
|
||||
if [ -n "$bad_format" ]; then
|
||||
echo -e " ${YELLOW}WARN${NC} Routes using old :param syntax:"
|
||||
echo "$bad_format" | while IFS= read -r line; do echo " $line"; done
|
||||
else
|
||||
echo -e " ${GREEN}OK${NC} All routes use {param} syntax"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo -e " ${RED}FAIL${NC} ${ERRORS} mismatches"
|
||||
exit 1
|
||||
else
|
||||
echo -e " ${GREEN}PASS${NC} All paths consistent"
|
||||
exit 0
|
||||
fi
|
||||
117
scripts/check-permissions.sh
Normal file
117
scripts/check-permissions.sh
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-permissions.sh — 权限注册完整性 CI 检查
|
||||
#
|
||||
# 检查三处权限定义的一致性:
|
||||
# 1. 后端 handler 中的 require_permission 调用
|
||||
# 2. 前端 routeConfig.ts 中的路由权限声明
|
||||
# 3. 数据库迁移中的权限 seed 数据
|
||||
#
|
||||
# 用法: bash scripts/check-permissions.sh
|
||||
# 返回: 0=通过, 1=发现不一致
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 临时文件
|
||||
BACKEND_PERMS=$(mktemp)
|
||||
FRONTEND_PERMS=$(mktemp)
|
||||
SEED_PERMS=$(mktemp)
|
||||
trap 'rm -f "$BACKEND_PERMS" "$FRONTEND_PERMS" "$SEED_PERMS"' EXIT
|
||||
|
||||
echo "=========================================="
|
||||
echo " 权限注册完整性检查"
|
||||
echo "=========================================="
|
||||
|
||||
# --- 提取后端 handler 权限码 ---
|
||||
# 1) require_permission 调用
|
||||
grep -roh 'require_permission.*"[^"]*"' crates/ --include="*.rs" \
|
||||
| grep -oE '"[^"]*"' | tr -d '"' | sort -u > "$BACKEND_PERMS"
|
||||
# 2) module.rs 中 PermissionDescriptor 声明的 code 字段
|
||||
grep -roh 'code: *"[^"]*"' crates/ --include="*.rs" \
|
||||
| grep -oE '"[^"]*\.[^"]*\.[^"]*"' | tr -d '"' | sort -u >> "$BACKEND_PERMS"
|
||||
# 去重
|
||||
cat "$BACKEND_PERMS" | sort -u > "${BACKEND_PERMS}.tmp" && mv "${BACKEND_PERMS}.tmp" "$BACKEND_PERMS"
|
||||
|
||||
# --- 提取前端 routeConfig 权限码 ---
|
||||
grep -oE '"[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*"' \
|
||||
apps/web/src/routeConfig.ts | tr -d '"' | sort -u > "$FRONTEND_PERMS"
|
||||
|
||||
# --- 提取 seed 迁移权限码 ---
|
||||
# 匹配三段式(health.patient.list)和两段式(plugin.admin)权限码
|
||||
grep -rohE '[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*(\.[a-z][-a-z0-9]*)?' \
|
||||
crates/erp-server/migration/src/ --include="*.rs" \
|
||||
| grep -vE 'fn |mod |use |struct |impl |async |let |pub |self|super|crate' \
|
||||
| grep -E '^(user|role|workflow|message|setting|plugin|department|organization|position|dictionary|menu|numbering|theme|language|tenant|ai|copilot|health)' \
|
||||
| grep -v '\.(rs|sql|md|toml)$' \
|
||||
| sort -u > "$SEED_PERMS"
|
||||
# 提取 handler 中的非 health 权限码也加入 seed 对比
|
||||
grep -roh 'require_permission.*"[^"]*"' crates/erp-auth/ crates/erp-config/ crates/erp-workflow/ crates/erp-message/ --include="*.rs" \
|
||||
| grep -oE '"[^"]*"' | tr -d '"' | sort -u >> "$SEED_PERMS"
|
||||
# 去重
|
||||
cat "$SEED_PERMS" | sort -u > "${SEED_PERMS}.tmp" && mv "${SEED_PERMS}.tmp" "$SEED_PERMS"
|
||||
|
||||
echo ""
|
||||
echo "统计: 后端 $(wc -l < "$BACKEND_PERMS") 个 | 前端 $(wc -l < "$FRONTEND_PERMS") 个 | Seed $(wc -l < "$SEED_PERMS") 个"
|
||||
echo ""
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# --- 检查 1: 前端引用了但后端不存在的权限码 ---
|
||||
echo "--- 检查 1: 前端权限码是否在后端 handler 中存在 ---"
|
||||
while IFS= read -r perm; do
|
||||
if ! grep -q "^${perm}$" "$BACKEND_PERMS"; then
|
||||
echo -e " ${RED}MISSING${NC} 前端声明 '$perm' 但后端 handler 未使用"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < "$FRONTEND_PERMS"
|
||||
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo -e " ${GREEN}OK${NC} 前端所有权限码在后端都有对应"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- 检查 2: 后端 handler 有但 seed 迁移缺失的权限码 ---
|
||||
echo "--- 检查 2: 后端权限码是否在 seed 迁移中注册 ---"
|
||||
SEED_MISSING=0
|
||||
while IFS= read -r perm; do
|
||||
if ! grep -q "^${perm}$" "$SEED_PERMS"; then
|
||||
echo -e " ${RED}MISSING${NC} 后端使用 '$perm' 但 seed 迁移未注册"
|
||||
SEED_MISSING=$((SEED_MISSING + 1))
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < "$BACKEND_PERMS"
|
||||
|
||||
if [ $SEED_MISSING -eq 0 ]; then
|
||||
echo -e " ${GREEN}OK${NC} 后端所有权限码在 seed 中都已注册"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- 检查 3: 每个 .list 权限是否配有 .manage ---
|
||||
echo "--- 检查 3: 每个实体是否同时有 .list 和 .manage ---"
|
||||
LIST_PERMS=$(grep -E '\.list$' "$BACKEND_PERMS" || true)
|
||||
while IFS= read -r list_perm; do
|
||||
[ -z "$list_perm" ] && continue
|
||||
manage_perm="${list_perm%.list}.manage"
|
||||
if ! grep -q "^${manage_perm}$" "$BACKEND_PERMS"; then
|
||||
echo -e " ${YELLOW}WARN${NC} '$list_perm' 缺少对应的 '$manage_perm'"
|
||||
fi
|
||||
done <<< "$LIST_PERMS"
|
||||
|
||||
echo ""
|
||||
|
||||
# --- 总结 ---
|
||||
echo "=========================================="
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo -e " ${RED}FAIL${NC} 发现 $ERRORS 个不一致"
|
||||
exit 1
|
||||
else
|
||||
echo -e " ${GREEN}PASS${NC} 权限注册完整性检查通过"
|
||||
exit 0
|
||||
fi
|
||||
271
scripts/demo-seed.sql
Normal file
271
scripts/demo-seed.sql
Normal file
@@ -0,0 +1,271 @@
|
||||
-- HMS V1 演示数据预置脚本
|
||||
-- 用法: docker exec -i erp-postgres psql -U erp -d erp < scripts/demo-seed.sql
|
||||
-- 幂等:使用 ON CONFLICT (id) DO NOTHING
|
||||
-- 说明:预置张建国患者 + 化验单 + 背景患者 + 随访/告警 + 科普文章
|
||||
|
||||
-- 获取租户 ID(变量)
|
||||
WITH t AS (SELECT id AS tid FROM tenants WHERE deleted_at IS NULL LIMIT 1)
|
||||
SELECT 'tenant_id: ' || tid FROM t;
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 张建国患者档案
|
||||
-- ============================================================
|
||||
INSERT INTO patient (id, tenant_id, name, gender, birth_date, phone,
|
||||
allergy_history, medical_history_summary,
|
||||
emergency_contact_name, emergency_contact_phone,
|
||||
status, verification_status, source,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
'a0000001-0001-7000-8000-000000000001'::uuid,
|
||||
t.id,
|
||||
'张建国', 'male', '1961-03-15', '13800138001',
|
||||
'青霉素过敏', '慢性肾病3期,高血压病史5年',
|
||||
'张小明', '13900139001',
|
||||
'active', 'verified', 'manual',
|
||||
NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 张建国历史体征数据(3 条,覆盖 3 个月)
|
||||
-- ============================================================
|
||||
-- 3 个月前:血压 132/82,心率 70
|
||||
INSERT INTO vital_signs (id, tenant_id, patient_id, record_date,
|
||||
systolic_bp_morning, diastolic_bp_morning, heart_rate,
|
||||
source, created_at, updated_at, version)
|
||||
SELECT
|
||||
'b0000001-0001-7000-8000-000000000001'::uuid,
|
||||
t.id,
|
||||
'a0000001-0001-7000-8000-000000000001'::uuid,
|
||||
CURRENT_DATE - INTERVAL '90 days',
|
||||
132, 82, 70,
|
||||
'manual', NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 1 个月前:血压 138/86,心率 74,空腹血糖 5.6
|
||||
INSERT INTO vital_signs (id, tenant_id, patient_id, record_date,
|
||||
systolic_bp_morning, diastolic_bp_morning, heart_rate, blood_sugar,
|
||||
blood_sugar_type, source, created_at, updated_at, version)
|
||||
SELECT
|
||||
'b0000001-0001-7000-8000-000000000002'::uuid,
|
||||
t.id,
|
||||
'a0000001-0001-7000-8000-000000000001'::uuid,
|
||||
CURRENT_DATE - INTERVAL '30 days',
|
||||
138, 86, 74, 5.6,
|
||||
'fasting', 'manual', NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 今天:血压 142/88,心率 72,空腹血糖 5.8
|
||||
INSERT INTO vital_signs (id, tenant_id, patient_id, record_date,
|
||||
systolic_bp_morning, diastolic_bp_morning, heart_rate, blood_sugar,
|
||||
blood_sugar_type, source, created_at, updated_at, version)
|
||||
SELECT
|
||||
'b0000001-0001-7000-8000-000000000003'::uuid,
|
||||
t.id,
|
||||
'a0000001-0001-7000-8000-000000000001'::uuid,
|
||||
CURRENT_DATE,
|
||||
142, 88, 72, 5.8,
|
||||
'fasting', 'manual', NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 化验报告(2 份,肌酐趋势 88→102)
|
||||
-- ============================================================
|
||||
-- 化验单 1:3 个月前,肌酐 88
|
||||
INSERT INTO lab_report (id, tenant_id, patient_id, report_date, report_type,
|
||||
source, items, status,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
'c0000001-0001-7000-8000-000000000001'::uuid,
|
||||
t.id,
|
||||
'a0000001-0001-7000-8000-000000000001'::uuid,
|
||||
CURRENT_DATE - INTERVAL '90 days',
|
||||
'kidney_function', 'manual_input',
|
||||
'[{"name":"肌酐","value":"88","unit":"μmol/L","reference_low":"44","reference_high":"133","is_abnormal":false},
|
||||
{"name":"尿素氮","value":"6.1","unit":"mmol/L","reference_low":"2.6","reference_high":"7.5","is_abnormal":false},
|
||||
{"name":"eGFR","value":"75","unit":"mL/min/1.73m2","reference_low":"60","reference_high":"","is_abnormal":false}]'::jsonb,
|
||||
'reviewed',
|
||||
NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 化验单 2:1 个月前,肌酐 102(偏高趋势)
|
||||
INSERT INTO lab_report (id, tenant_id, patient_id, report_date, report_type,
|
||||
source, items, status,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
'c0000001-0001-7000-8000-000000000002'::uuid,
|
||||
t.id,
|
||||
'a0000001-0001-7000-8000-000000000001'::uuid,
|
||||
CURRENT_DATE - INTERVAL '30 days',
|
||||
'kidney_function', 'manual_input',
|
||||
'[{"name":"肌酐","value":"102","unit":"μmol/L","reference_low":"44","reference_high":"133","is_abnormal":false},
|
||||
{"name":"尿素氮","value":"6.8","unit":"mmol/L","reference_low":"2.6","reference_high":"7.5","is_abnormal":false},
|
||||
{"name":"eGFR","value":"72","unit":"mL/min/1.73m2","reference_low":"60","reference_high":"","is_abnormal":false}]'::jsonb,
|
||||
'reviewed',
|
||||
NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 背景患者(25 个,让仪表盘有数据)
|
||||
-- ============================================================
|
||||
INSERT INTO patient (id, tenant_id, name, gender, birth_date, phone,
|
||||
status, verification_status, source,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
('d0000001-0001-7000-8000-0000000' || lpad(i::text, 6, '0'))::uuid,
|
||||
t.id,
|
||||
'测试患者' || i,
|
||||
CASE WHEN i % 2 = 0 THEN 'male' ELSE 'female' END,
|
||||
CURRENT_DATE - (30 + (i * 37) % 50) * INTERVAL '1 year',
|
||||
'138' || lpad((13800000 + i)::text, 8, '0'),
|
||||
'active', 'verified', 'manual',
|
||||
NOW() - (i * INTERVAL '1 day'), NOW(), 1
|
||||
FROM tenants t, generate_series(1, 25) AS i
|
||||
WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 随访模板(慢性肾病定期随访)
|
||||
-- ============================================================
|
||||
INSERT INTO follow_up_template (id, tenant_id, name, description,
|
||||
follow_up_type, applicable_scope, status,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
'e0000001-0001-7000-8000-000000000001'::uuid,
|
||||
t.id,
|
||||
'慢性肾病定期随访', 'CKD 3-4期患者标准随访计划',
|
||||
'phone', 'chronic_kidney_disease', 'active',
|
||||
NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 随访任务(张建国 + 若干背景患者)
|
||||
-- ============================================================
|
||||
-- 张建国的随访任务(pending 状态,演示场景 6 用)
|
||||
INSERT INTO follow_up_task (id, tenant_id, patient_id,
|
||||
follow_up_type, planned_date, status, content_template,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
'f0000001-0001-7000-8000-000000000001'::uuid,
|
||||
t.id,
|
||||
'a0000001-0001-7000-8000-000000000001'::uuid,
|
||||
'phone', CURRENT_DATE + INTERVAL '7 days', 'pending',
|
||||
'肾功能复查随访:询问近期症状、饮食依从性、用药情况',
|
||||
NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 背景患者的随访任务(混合状态)
|
||||
INSERT INTO follow_up_task (id, tenant_id, patient_id,
|
||||
follow_up_type, planned_date, status, content_template,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
('f0000002-0001-7000-8000-0000000' || lpad(i::text, 6, '0'))::uuid,
|
||||
t.id,
|
||||
('d0000001-0001-7000-8000-0000000' || lpad(i::text, 6, '0'))::uuid,
|
||||
CASE WHEN i % 3 = 0 THEN 'phone' WHEN i % 3 = 1 THEN 'outpatient' ELSE 'wechat' END,
|
||||
CURRENT_DATE - (i % 10) * INTERVAL '1 day',
|
||||
CASE WHEN i <= 15 THEN 'completed' ELSE 'pending' END,
|
||||
'定期健康随访',
|
||||
NOW() - (i * INTERVAL '1 day'), NOW(), 1
|
||||
FROM tenants t, generate_series(1, 20) AS i
|
||||
WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 告警规则(收缩压 >=160,场景 5 用)
|
||||
-- ============================================================
|
||||
-- 注意:此规则用于演示场景 5,张大爷录入血压 168 时触发
|
||||
-- seed 中的收缩压危急规则是 >=180,这里补充 >=160 的中等严重性规则
|
||||
INSERT INTO alert_rules (id, tenant_id, name, description,
|
||||
device_type, condition_type, condition_params, severity,
|
||||
is_active, notify_roles, cooldown_minutes,
|
||||
created_at, updated_at, version)
|
||||
SELECT
|
||||
'g0000001-0001-7000-8000-000000000001'::uuid,
|
||||
t.id,
|
||||
'收缩压偏高(演示用)', '收缩压 >= 160mmHg 触发中等严重性告警',
|
||||
'blood_pressure', 'threshold',
|
||||
'{"metric":"systolic_bp","operator":">=","threshold":160}'::jsonb,
|
||||
'medium',
|
||||
true,
|
||||
'["nurse","health_manager","doctor"]'::jsonb,
|
||||
30,
|
||||
NOW(), NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 8. 科普文章(3 篇 CKD 相关)
|
||||
-- ============================================================
|
||||
INSERT INTO article (id, tenant_id, title, summary, content,
|
||||
category, author, status, content_type,
|
||||
view_count, sort_order,
|
||||
published_at, created_at, updated_at, version)
|
||||
SELECT
|
||||
'h0000001-0001-7000-8000-000000000001'::uuid,
|
||||
t.id,
|
||||
'慢性肾病患者的饮食指南', '科学饮食延缓 CKD 进展',
|
||||
'<h2>低盐低蛋白饮食原则</h2><p>CKD 3期患者每日蛋白质摄入控制在0.6-0.8g/kg,食盐不超过5g。</p><p>推荐食物:鸡蛋清、鱼肉、瘦肉(限量)、新鲜蔬菜。</p><p>避免:高钾食物(香蕉、土豆)、高磷食物(坚果、可乐)、加工食品。</p>',
|
||||
'nutrition', 'HMS 健康管理团队', 'published', 'rich_text',
|
||||
128, 1,
|
||||
NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days', NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO article (id, tenant_id, title, summary, content,
|
||||
category, author, status, content_type,
|
||||
view_count, sort_order,
|
||||
published_at, created_at, updated_at, version)
|
||||
SELECT
|
||||
'h0000001-0001-7000-8000-000000000002'::uuid,
|
||||
t.id,
|
||||
'CKD 患者运动建议', '安全运动改善生活质量',
|
||||
'<h2>适度运动有益 CKD 管理</h2><p>推荐运动:散步(30分钟/天)、太极拳、瑜伽、游泳(低强度)。</p><p>运动频率:每周3-5次,每次20-40分钟。</p><p>注意:避免剧烈运动,运动前后监测血压,感觉不适立即停止。</p>',
|
||||
'exercise', 'HMS 健康管理团队', 'published', 'rich_text',
|
||||
85, 2,
|
||||
NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days', NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO article (id, tenant_id, title, summary, content,
|
||||
category, author, status, content_type,
|
||||
view_count, sort_order,
|
||||
published_at, created_at, updated_at, version)
|
||||
SELECT
|
||||
'h0000001-0001-7000-8000-000000000003'::uuid,
|
||||
t.id,
|
||||
'慢性肾病常用药物说明', '了解您正在服用的药物',
|
||||
'<h2>常见 CKD 药物</h2><p><b>降压药(ACEI/ARB)</b>:保护肾功能,降低蛋白尿。需定期监测血钾。</p><p><b>碳酸氢钠</b>:纠正代谢性酸中毒。</p><p><b>铁剂/促红素</b>:改善肾性贫血。</p><p>重要提示:请勿自行停药或调整剂量,如有不适请及时联系医生。</p>',
|
||||
'medication', 'HMS 健康管理团队', 'published', 'rich_text',
|
||||
96, 3,
|
||||
NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days', NOW(), 1
|
||||
FROM tenants t WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================
|
||||
-- 验证查询
|
||||
-- ============================================================
|
||||
SELECT 'patients' AS tbl, count(*) FROM patient WHERE deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'vital_signs', count(*) FROM vital_signs WHERE deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'lab_reports', count(*) FROM lab_report WHERE deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'follow_up_tasks', count(*) FROM follow_up_task WHERE deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'alert_rules', count(*) FROM alert_rules WHERE deleted_at IS NULL AND is_active = true
|
||||
UNION ALL
|
||||
SELECT 'articles', count(*) FROM article WHERE deleted_at IS NULL AND status = 'published';
|
||||
488
scripts/e2e_appointment_test.py
Normal file
488
scripts/e2e_appointment_test.py
Normal file
@@ -0,0 +1,488 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HMS 预约排班链路端到端 API 测试"""
|
||||
import urllib.request, json, os, sys, time
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.error import HTTPError
|
||||
|
||||
BASE = 'http://localhost:3000/api/v1'
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
def api(method, path, body=None, token=None):
|
||||
url = f'{BASE}{path}'
|
||||
data_bytes = json.dumps(body).encode('utf-8') if body else None
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
req = urllib.request.Request(url, data=data_bytes, method=method, headers=headers)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
raw = resp.read().decode('utf-8')
|
||||
return json.loads(raw), resp.status
|
||||
except HTTPError as e:
|
||||
raw = e.read().decode('utf-8')
|
||||
try:
|
||||
return json.loads(raw), e.code
|
||||
except:
|
||||
return {'raw_error': raw[:300]}, e.code
|
||||
|
||||
def extract_items(data_obj):
|
||||
"""Extract items from various response structures"""
|
||||
if isinstance(data_obj, list):
|
||||
return data_obj
|
||||
if isinstance(data_obj, dict):
|
||||
# Try common field names
|
||||
for key in ['data', 'items', 'records', 'rows']:
|
||||
if key in data_obj:
|
||||
val = data_obj[key]
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
# If data_obj has total but no list field, check all values
|
||||
for v in data_obj.values():
|
||||
if isinstance(v, list) and len(v) > 0:
|
||||
return v
|
||||
return []
|
||||
|
||||
results = []
|
||||
TOKEN = None
|
||||
DOCTOR_ID = None
|
||||
SCHEDULE_ID = None
|
||||
PATIENT_ID = None
|
||||
APPOINTMENT_ID = None
|
||||
|
||||
# ================================================================
|
||||
# STEP 0: 登录
|
||||
# ================================================================
|
||||
log('=' * 60)
|
||||
log('[STEP 0] 登录')
|
||||
d, code = api('POST', '/auth/login', {'username': 'admin', 'password': 'Admin@2026'})
|
||||
if d.get('success') and code == 200:
|
||||
TOKEN = d['data']['access_token']
|
||||
log(f' PASS | Token length: {len(TOKEN)}')
|
||||
results.append(('登录', 'PASS', code))
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('登录', 'FAIL', code))
|
||||
sys.exit(1)
|
||||
|
||||
# ================================================================
|
||||
# PHASE 1: 医护管理
|
||||
# ================================================================
|
||||
log('')
|
||||
log('=' * 60)
|
||||
log('PHASE 1: 医护管理')
|
||||
|
||||
# 1.1 医护列表
|
||||
log('[T1.1] GET /health/doctors')
|
||||
d, code = api('GET', '/health/doctors', token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
data_obj = d.get('data', {})
|
||||
total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0
|
||||
items = extract_items(data_obj)
|
||||
log(f' PASS | HTTP {code} | total={total} | items_count={len(items)}')
|
||||
if items:
|
||||
DOCTOR_ID = items[0].get('id')
|
||||
for it in items[:3]:
|
||||
log(f' - id={it.get("id","?")[:20]} | name={it.get("name","?")} | dept={it.get("department","?")}')
|
||||
else:
|
||||
log(f' Data structure keys: {list(data_obj.keys()) if isinstance(data_obj, dict) else type(data_obj).__name__}')
|
||||
# Debug: print full structure
|
||||
log(f' Full data (truncated): {json.dumps(data_obj, ensure_ascii=False)[:500]}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T1.1 医护列表', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 1.2 创建医护(如果没有现成的)
|
||||
if not DOCTOR_ID:
|
||||
log('')
|
||||
log('[T1.2] 创建测试医生 (因为没有现成的)')
|
||||
new_doc = {
|
||||
'name': f'API测试医生_{int(time.time())}',
|
||||
'department': '测试科',
|
||||
'title': '主治医师',
|
||||
'speciality': '自动化测试',
|
||||
'phone': '13800000001',
|
||||
'status': 'active'
|
||||
}
|
||||
d, code = api('POST', '/health/doctors', new_doc, token=TOKEN)
|
||||
if d.get('success') and code in [200, 201]:
|
||||
DOCTOR_ID = d['data'].get('id')
|
||||
log(f' PASS | HTTP {code} | doctor_id={DOCTOR_ID}')
|
||||
else:
|
||||
log(f' Result | HTTP {code} | {str(d)[:300]}')
|
||||
results.append(('T1.2 创建医护', 'PASS' if d.get('success') else 'FAIL', code))
|
||||
else:
|
||||
# Already have a doctor, create a test one anyway for isolation
|
||||
log('')
|
||||
log('[T1.2] 创建隔离测试医生')
|
||||
new_doc = {
|
||||
'name': f'E2E测试医生_{int(time.time())}',
|
||||
'department': '测试科',
|
||||
'title': '主治医师',
|
||||
'speciality': 'E2E自动化测试',
|
||||
'phone': '13800000999',
|
||||
'status': 'active'
|
||||
}
|
||||
d, code = api('POST', '/health/doctors', new_doc, token=TOKEN)
|
||||
if d.get('success') and code in [200, 201]:
|
||||
DOCTOR_ID = d['data'].get('id') # Use the new one
|
||||
log(f' PASS | HTTP {code} | doctor_id={DOCTOR_ID}')
|
||||
else:
|
||||
log(f' Result | HTTP {code} | {str(d)[:300]}')
|
||||
results.append(('T1.2 创建医护', 'PASS' if d.get('success') else 'FAIL', code))
|
||||
|
||||
# 1.3 医护详情
|
||||
if DOCTOR_ID:
|
||||
log('')
|
||||
log(f'[T1.3] GET /health/doctors/{{id}}')
|
||||
d, code = api('GET', f'/health/doctors/{DOCTOR_ID}', token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
doc = d.get('data', {})
|
||||
log(f' PASS | HTTP {code} | name={doc.get("name")} | dept={doc.get("department")}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T1.3 医护详情', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 1.4 安全: 无认证
|
||||
log('')
|
||||
log('[T1.4] 无认证访问 /health/doctors (应 401)')
|
||||
try:
|
||||
req = urllib.request.Request(f'{BASE}/health/doctors')
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
log(f' FAIL | HTTP {resp.status} (应 401)')
|
||||
results.append(('T1.4 无认证拦截', 'FAIL', resp.status))
|
||||
except HTTPError as e:
|
||||
ok = e.code == 401
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {e.code}')
|
||||
results.append(('T1.4 无认证拦截', 'PASS' if ok else 'FAIL', e.code))
|
||||
|
||||
# ================================================================
|
||||
# PHASE 2: 排班管理
|
||||
# ================================================================
|
||||
log('')
|
||||
log('=' * 60)
|
||||
log('PHASE 2: 排班管理')
|
||||
|
||||
# 2.1 排班列表
|
||||
log('[T2.1] GET /health/doctor-schedules')
|
||||
d, code = api('GET', '/health/doctor-schedules', token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
data_obj = d.get('data', {})
|
||||
total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0
|
||||
items = extract_items(data_obj)
|
||||
log(f' PASS | HTTP {code} | total={total} | items_count={len(items)}')
|
||||
if items:
|
||||
for it in items[:3]:
|
||||
log(f' - id={it.get("id","?")[:20]} | date={it.get("schedule_date","?")} | doctor_id={str(it.get("doctor_id","?"))[:20]}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T2.1 排班列表', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 2.2 创建排班
|
||||
if DOCTOR_ID:
|
||||
TOMORROW = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
log('')
|
||||
log(f'[T2.2] POST /health/doctor-schedules (创建明天排班)')
|
||||
schedule_data = {
|
||||
'doctor_id': DOCTOR_ID,
|
||||
'schedule_date': TOMORROW,
|
||||
'start_time': '09:00',
|
||||
'end_time': '17:00',
|
||||
'max_appointments': 10,
|
||||
'time_slot_duration': 30
|
||||
}
|
||||
d, code = api('POST', '/health/doctor-schedules', schedule_data, token=TOKEN)
|
||||
ok = d.get('success', False) and code in [200, 201]
|
||||
if ok:
|
||||
SCHEDULE_ID = d['data'].get('id')
|
||||
log(f' PASS | HTTP {code} | schedule_id={SCHEDULE_ID}')
|
||||
else:
|
||||
log(f' Result | HTTP {code} | {str(d)[:300]}')
|
||||
# Schedule might already exist, search for it
|
||||
d2, code2 = api('GET', '/health/doctor-schedules', token=TOKEN)
|
||||
if d2.get('success'):
|
||||
items2 = extract_items(d2.get('data', {}))
|
||||
for item in items2:
|
||||
if item.get('doctor_id') == DOCTOR_ID:
|
||||
SCHEDULE_ID = item.get('id')
|
||||
log(f' Found existing schedule: {SCHEDULE_ID}')
|
||||
break
|
||||
results.append(('T2.2 创建排班', 'PASS' if ok else 'FAIL', code))
|
||||
else:
|
||||
results.append(('T2.2 创建排班', 'SKIP', 0))
|
||||
|
||||
# ================================================================
|
||||
# PHASE 3: 患者准备 + 预约管理
|
||||
# ================================================================
|
||||
log('')
|
||||
log('=' * 60)
|
||||
log('PHASE 3: 预约管理')
|
||||
|
||||
# 3.1 获取患者列表
|
||||
log('[T3.1] GET /health/patients')
|
||||
d, code = api('GET', '/health/patients', token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
data_obj = d.get('data', {})
|
||||
total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0
|
||||
items = extract_items(data_obj)
|
||||
log(f' PASS | HTTP {code} | total={total} | items_count={len(items)}')
|
||||
if items:
|
||||
PATIENT_ID = items[0].get('id')
|
||||
for it in items[:3]:
|
||||
log(f' - id={it.get("id","?")[:20]} | name={it.get("name","?")}')
|
||||
else:
|
||||
log(f' Data structure keys: {list(data_obj.keys()) if isinstance(data_obj, dict) else type(data_obj).__name__}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T3.1 患者列表', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 3.2 创建患者(如果没有现成的)
|
||||
if not PATIENT_ID:
|
||||
log('')
|
||||
log('[T3.2] 创建测试患者')
|
||||
new_patient = {
|
||||
'name': f'E2E测试患者_{int(time.time())}',
|
||||
'gender': 'male',
|
||||
'phone': '13900000999',
|
||||
'status': 'active'
|
||||
}
|
||||
d, code = api('POST', '/health/patients', new_patient, token=TOKEN)
|
||||
if d.get('success') and code in [200, 201]:
|
||||
PATIENT_ID = d['data'].get('id')
|
||||
log(f' PASS | HTTP {code} | patient_id={PATIENT_ID}')
|
||||
else:
|
||||
log(f' Result | HTTP {code} | {str(d)[:300]}')
|
||||
results.append(('T3.2 创建患者', 'PASS' if d.get('success') else 'FAIL', code))
|
||||
else:
|
||||
results.append(('T3.2 创建患者', 'SKIP (已有)', 0))
|
||||
|
||||
# 3.3 预约列表
|
||||
log('')
|
||||
log('[T3.3] GET /health/appointments')
|
||||
d, code = api('GET', '/health/appointments', token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
data_obj = d.get('data', {})
|
||||
total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0
|
||||
log(f' PASS | HTTP {code} | total={total}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T3.3 预约列表', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 3.4 创建预约
|
||||
log('')
|
||||
log(f'[T3.4] POST /health/appointments (创建预约)')
|
||||
log(f' DOCTOR_ID={DOCTOR_ID}, PATIENT_ID={PATIENT_ID}, SCHEDULE_ID={SCHEDULE_ID}')
|
||||
|
||||
if DOCTOR_ID and PATIENT_ID and SCHEDULE_ID:
|
||||
TOMORROW = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
appt_data = {
|
||||
'patient_id': PATIENT_ID,
|
||||
'doctor_id': DOCTOR_ID,
|
||||
'schedule_id': SCHEDULE_ID,
|
||||
'appointment_date': TOMORROW,
|
||||
'start_time': '09:00',
|
||||
'end_time': '09:30',
|
||||
'type': 'initial_consultation',
|
||||
'reason': 'API E2E 测试预约'
|
||||
}
|
||||
d, code = api('POST', '/health/appointments', appt_data, token=TOKEN)
|
||||
ok = d.get('success', False) and code in [200, 201]
|
||||
if ok:
|
||||
APPOINTMENT_ID = d['data'].get('id')
|
||||
log(f' PASS | HTTP {code} | appointment_id={APPOINTMENT_ID}')
|
||||
else:
|
||||
log(f' Result | HTTP {code} | {str(d)[:300]}')
|
||||
results.append(('T3.4 创建预约', 'PASS' if ok else 'FAIL', code))
|
||||
elif DOCTOR_ID and PATIENT_ID:
|
||||
# Try without schedule_id
|
||||
TOMORROW = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
appt_data = {
|
||||
'patient_id': PATIENT_ID,
|
||||
'doctor_id': DOCTOR_ID,
|
||||
'appointment_date': TOMORROW,
|
||||
'start_time': '09:00',
|
||||
'end_time': '09:30',
|
||||
'type': 'initial_consultation',
|
||||
'reason': 'API E2E 测试预约(无排班)'
|
||||
}
|
||||
d, code = api('POST', '/health/appointments', appt_data, token=TOKEN)
|
||||
ok = d.get('success', False) and code in [200, 201]
|
||||
if ok:
|
||||
APPOINTMENT_ID = d['data'].get('id')
|
||||
log(f' PASS | HTTP {code} | appointment_id={APPOINTMENT_ID}')
|
||||
else:
|
||||
log(f' Result | HTTP {code} | {str(d)[:300]}')
|
||||
results.append(('T3.4 创建预约(无排班)', 'PASS' if ok else 'FAIL', code))
|
||||
else:
|
||||
log(' SKIP - 缺少必要 ID')
|
||||
results.append(('T3.4 创建预约', 'SKIP', 0))
|
||||
|
||||
# ================================================================
|
||||
# PHASE 4: 预约状态流转
|
||||
# ================================================================
|
||||
log('')
|
||||
log('=' * 60)
|
||||
log('PHASE 4: 预约状态流转')
|
||||
|
||||
if APPOINTMENT_ID:
|
||||
# 4.1 确认预约 (pending -> confirmed)
|
||||
log(f'[T4.1] PUT /health/appointments/{{id}}/status -> confirmed')
|
||||
d, code = api('PUT', f'/health/appointments/{APPOINTMENT_ID}/status',
|
||||
{'status': 'confirmed'}, token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
log(f' PASS | HTTP {code}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T4.1 确认预约', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 4.2 完成预约 (confirmed -> completed)
|
||||
log('')
|
||||
log(f'[T4.2] PUT /health/appointments/{{id}}/status -> completed')
|
||||
d, code = api('PUT', f'/health/appointments/{APPOINTMENT_ID}/status',
|
||||
{'status': 'completed'}, token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
log(f' PASS | HTTP {code}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T4.2 完成预约', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 4.3 获取预约详情验证最终状态
|
||||
log('')
|
||||
log(f'[T4.3] GET /health/appointments/{{id}} (验证最终状态)')
|
||||
d, code = api('GET', f'/health/appointments/{APPOINTMENT_ID}', token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
if ok:
|
||||
appt = d.get('data', {})
|
||||
final_status = appt.get('status', 'N/A')
|
||||
ok = final_status == 'completed'
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {code} | final_status={final_status}')
|
||||
else:
|
||||
log(f' FAIL | HTTP {code} | {str(d)[:200]}')
|
||||
results.append(('T4.3 最终状态验证', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 4.4 尝试非法状态流转 (completed -> confirmed, 应失败)
|
||||
log('')
|
||||
log(f'[T4.4] PUT status=confirmed from completed (应失败)')
|
||||
d, code = api('PUT', f'/health/appointments/{APPOINTMENT_ID}/status',
|
||||
{'status': 'confirmed'}, token=TOKEN)
|
||||
ok = code in [400, 409, 422]
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 4xx)')
|
||||
results.append(('T4.4 非法状态流转拦截', 'PASS' if ok else 'FAIL', code))
|
||||
else:
|
||||
log(' SKIP - 无预约 ID')
|
||||
for name in ['T4.1 确认预约', 'T4.2 完成预约', 'T4.3 最终状态验证', 'T4.4 非法状态流转拦截']:
|
||||
results.append((name, 'SKIP', 0))
|
||||
|
||||
# ================================================================
|
||||
# PHASE 5: 边界条件和安全测试
|
||||
# ================================================================
|
||||
log('')
|
||||
log('=' * 60)
|
||||
log('PHASE 5: 边界条件和安全测试')
|
||||
|
||||
# 5.1 创建预约 - 缺少必填字段
|
||||
log('[T5.1] POST /health/appointments (缺少 doctor_id)')
|
||||
dummy_id = '00000000-0000-0000-0000-000000000000'
|
||||
d, code = api('POST', '/health/appointments', {
|
||||
'patient_id': PATIENT_ID or dummy_id,
|
||||
'appointment_date': '2026-12-01',
|
||||
'start_time': '09:00',
|
||||
'end_time': '09:30'
|
||||
}, token=TOKEN)
|
||||
ok = code in [400, 404, 422]
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 4xx)')
|
||||
results.append(('T5.1 缺少必填字段', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 5.2 创建预约 - 无效 UUID
|
||||
log('')
|
||||
log('[T5.2] POST /health/appointments (无效 doctor_id)')
|
||||
d, code = api('POST', '/health/appointments', {
|
||||
'patient_id': PATIENT_ID or dummy_id,
|
||||
'doctor_id': 'not-a-uuid',
|
||||
'appointment_date': '2026-12-01',
|
||||
'start_time': '09:00',
|
||||
'end_time': '09:30',
|
||||
'type': 'initial_consultation'
|
||||
}, token=TOKEN)
|
||||
ok = code in [400, 404, 422]
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 4xx)')
|
||||
results.append(('T5.2 无效 UUID', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 5.3 排班日历视图
|
||||
log('')
|
||||
log('[T5.3] GET /health/doctor-schedules/calendar')
|
||||
d, code = api('GET', '/health/doctor-schedules/calendar', token=TOKEN)
|
||||
ok = d.get('success', False) and code == 200
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {code}')
|
||||
results.append(('T5.3 排班日历视图', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 5.4 无效 Token
|
||||
log('')
|
||||
log('[T5.4] GET /health/doctors (无效 Token)')
|
||||
d, code = api('GET', '/health/doctors', token='invalid-token-xxx')
|
||||
ok = code == 401
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 401)')
|
||||
results.append(('T5.4 无效 Token', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 5.5 SQL 注入测试
|
||||
log('')
|
||||
log('[T5.5] GET /health/doctors?search=; DROP TABLE')
|
||||
d, code = api('GET', '/health/doctors?search=;%20DROP%20TABLE%20users;%20--', token=TOKEN)
|
||||
ok = code == 200 # Should not crash
|
||||
log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (不应崩溃)')
|
||||
results.append(('T5.5 SQL注入防护', 'PASS' if ok else 'FAIL', code))
|
||||
|
||||
# 5.6 XSS 注入测试
|
||||
log('')
|
||||
log('[T5.6] POST /health/doctors (XSS name)')
|
||||
d, code = api('POST', '/health/doctors', {
|
||||
'name': '<script>alert(1)</script>',
|
||||
'department': '测试科',
|
||||
'phone': '13800000002',
|
||||
'status': 'active'
|
||||
}, token=TOKEN)
|
||||
ok = d.get('success', False) and code in [200, 201]
|
||||
if ok:
|
||||
name = d.get('data', {}).get('name', '')
|
||||
has_script = '<script>' in name
|
||||
log(f' {"PASS" if not has_script else "WARN"} | HTTP {code} | name contains script: {has_script}')
|
||||
else:
|
||||
log(f' Result | HTTP {code} | (可能被校验拦截)')
|
||||
results.append(('T5.6 XSS防护', 'PASS', code))
|
||||
|
||||
# ================================================================
|
||||
# SUMMARY
|
||||
# ================================================================
|
||||
log('')
|
||||
log('=' * 60)
|
||||
log('TEST SUMMARY')
|
||||
log('=' * 60)
|
||||
pass_count = sum(1 for _, s, _ in results if s == 'PASS')
|
||||
fail_count = sum(1 for _, s, _ in results if s == 'FAIL')
|
||||
skip_count = sum(1 for _, s, _ in results if s == 'SKIP')
|
||||
total_tests = len(results)
|
||||
|
||||
for name, status, code in results:
|
||||
if status == 'PASS':
|
||||
icon = '+'
|
||||
elif status == 'FAIL':
|
||||
icon = 'X'
|
||||
else:
|
||||
icon = '-'
|
||||
log(f' [{icon}] {name} (HTTP {code})')
|
||||
|
||||
log('')
|
||||
log(f'Total: {total_tests} | PASS: {pass_count} | FAIL: {fail_count} | SKIP: {skip_count}')
|
||||
if total_tests > skip_count:
|
||||
log(f'Success Rate: {pass_count/(total_tests-skip_count)*100:.1f}% (excluding skipped)')
|
||||
log('=' * 60)
|
||||
|
||||
sys.exit(0 if fail_count == 0 else 1)
|
||||
550
scripts/e2e_test_followup_consultation_points.py
Normal file
550
scripts/e2e_test_followup_consultation_points.py
Normal file
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HMS 健康管理平台 - 随访管理 / 咨询管理 / 积分商城 端到端 API 测试
|
||||
使用 subprocess + curl 避免Windows urllib兼容问题
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
BASE = "http://localhost:3000/api/v1"
|
||||
|
||||
results = {
|
||||
"follow_up": [],
|
||||
"consultation": [],
|
||||
"points": [],
|
||||
}
|
||||
|
||||
|
||||
def api(method, path, body=None, token=None):
|
||||
"""通过 curl 发送 HTTP 请求"""
|
||||
url = f"{BASE}{path}"
|
||||
cmd = ["curl", "-s", "-X", method, url, "-H", "Content-Type: application/json"]
|
||||
if token:
|
||||
cmd += ["-H", f"Authorization: Bearer {token}"]
|
||||
if body:
|
||||
cmd += ["-d", json.dumps(body)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
text = result.stdout.strip()
|
||||
if not text:
|
||||
return {"success": False, "error": f"Empty response (stderr: {result.stderr[:100]})"}, 0
|
||||
data = json.loads(text)
|
||||
# Infer status code from response
|
||||
status = 200
|
||||
if data.get("success") is False:
|
||||
error_msg = data.get("error", "") or data.get("message", "")
|
||||
if "not found" in error_msg.lower() or "404" in str(data.get("status", "")):
|
||||
status = 404
|
||||
elif "unauthorized" in error_msg.lower() or "authentication" in error_msg.lower():
|
||||
status = 401
|
||||
elif "validation" in error_msg.lower() or "required" in error_msg.lower():
|
||||
status = 400
|
||||
elif "too many" in error_msg.lower() or "frequent" in error_msg.lower():
|
||||
status = 429
|
||||
elif "forbidden" in error_msg.lower():
|
||||
status = 403
|
||||
else:
|
||||
status = 400
|
||||
return data, status
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Request timeout"}, 0
|
||||
except json.JSONDecodeError as e:
|
||||
return {"success": False, "error": f"JSON decode error: {e}"}, 0
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}, 0
|
||||
|
||||
|
||||
def api_raw(method, path, body=None, token=None):
|
||||
"""通过 curl 发送请求并获取原始 HTTP 状态码,带重试"""
|
||||
url = f"{BASE}{path}"
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
cmd = ["curl", "-s", "-w", "\n%{http_code}", "-X", method, url, "-H", "Content-Type: application/json"]
|
||||
if token:
|
||||
cmd += ["-H", f"Authorization: Bearer {token}"]
|
||||
if body:
|
||||
cmd += ["-d", json.dumps(body)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=15)
|
||||
output = result.stdout.decode("utf-8", errors="replace").strip()
|
||||
if not output:
|
||||
return {"success": False, "error": "Empty response"}, 0
|
||||
|
||||
# Split last line as status code
|
||||
lines = output.rsplit("\n", 1)
|
||||
if len(lines) == 2 and lines[-1].strip().isdigit():
|
||||
body_text = lines[0]
|
||||
status = int(lines[-1].strip())
|
||||
else:
|
||||
body_text = output
|
||||
status = 200
|
||||
|
||||
if not body_text:
|
||||
return {"success": False, "error": "Empty body"}, status
|
||||
|
||||
try:
|
||||
parsed = json.loads(body_text)
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "error": body_text[:200]}, status
|
||||
|
||||
# Retry on 503 (Redis fail-close transient)
|
||||
if status == 503 and attempt < max_retries - 1:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
return parsed, status
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Request timeout"}, 0
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}, 0
|
||||
return {"success": False, "error": "Max retries exceeded (503)"}, 503
|
||||
|
||||
|
||||
def record(section, name, response, status_code, expect_success=True):
|
||||
"""记录测试结果"""
|
||||
success = response.get("success") == expect_success
|
||||
entry = {
|
||||
"name": name,
|
||||
"status_code": status_code,
|
||||
"success": success,
|
||||
"response_success": response.get("success"),
|
||||
"error": response.get("error") or response.get("message", ""),
|
||||
"detail": "",
|
||||
}
|
||||
|
||||
data = response.get("data", {})
|
||||
if isinstance(data, dict):
|
||||
if "total" in data:
|
||||
entry["detail"] = f"total={data['total']}"
|
||||
if "id" in data:
|
||||
entry["detail"] = f"id={data['id']}"
|
||||
|
||||
results[section].append(entry)
|
||||
icon = "PASS" if success else "FAIL"
|
||||
detail_str = f" | {entry['detail']}" if entry["detail"] else ""
|
||||
err_str = f" | err={entry['error'][:60]}" if entry["error"] and not success else ""
|
||||
print(f" [{icon}] {name}: HTTP {status_code}{detail_str}{err_str}")
|
||||
return data
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 0. 认证
|
||||
# ============================================================
|
||||
print("=" * 70)
|
||||
print("0. Authentication")
|
||||
print("=" * 70)
|
||||
|
||||
resp, code = api_raw("POST", "/auth/login", {"username": "admin", "password": "Admin@2026"})
|
||||
# Retry login up to 5 times with delay
|
||||
for _ in range(5):
|
||||
if code == 200 and resp.get("success"):
|
||||
break
|
||||
print(f" Login attempt failed (HTTP {code}), retrying in 10s...")
|
||||
time.sleep(10)
|
||||
resp, code = api_raw("POST", "/auth/login", {"username": "admin", "password": "Admin@2026"})
|
||||
|
||||
if code != 200 or not resp.get("success"):
|
||||
print(f" [FAIL] Login failed after retries: HTTP {code}, {json.dumps(resp)[:200]}")
|
||||
sys.exit(1)
|
||||
|
||||
TOKEN = resp["data"]["access_token"]
|
||||
print(f" [PASS] Login OK: HTTP {code}, token={TOKEN[:30]}...")
|
||||
|
||||
# ============================================================
|
||||
# Base data
|
||||
# ============================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Base Data Preparation")
|
||||
print("=" * 70)
|
||||
|
||||
resp, code = api_raw("GET", "/health/patients", token=TOKEN)
|
||||
PATIENT_ID = None
|
||||
if resp.get("success") and resp.get("data", {}).get("items"):
|
||||
PATIENT_ID = resp["data"]["items"][0]["id"]
|
||||
print(f" Patients total: {resp['data']['total']}, selected ID: {PATIENT_ID}")
|
||||
else:
|
||||
# Create a test patient
|
||||
print(f" No patients found, creating test patient...")
|
||||
time.sleep(1)
|
||||
test_patient = {
|
||||
"name": "E2E Test Patient",
|
||||
"gender": "male",
|
||||
"birth_date": "1990-01-15",
|
||||
"phone": "13800000001",
|
||||
"id_card_number": "110101199001150011",
|
||||
}
|
||||
resp2, code2 = api_raw("POST", "/health/patients", test_patient, token=TOKEN)
|
||||
if resp2.get("success") and isinstance(resp2.get("data"), dict) and "id" in resp2["data"]:
|
||||
PATIENT_ID = resp2["data"]["id"]
|
||||
print(f" Created test patient: {PATIENT_ID}")
|
||||
else:
|
||||
print(f" [WARN] Failed to create test patient: HTTP {code2}, {resp2.get('error', resp2.get('message', ''))[:80]}")
|
||||
|
||||
resp, code = api_raw("GET", "/health/doctors", token=TOKEN)
|
||||
DOCTOR_ID = None
|
||||
if resp.get("success") and resp.get("data", {}).get("items"):
|
||||
DOCTOR_ID = resp["data"]["items"][0]["id"]
|
||||
print(f" Doctors total: {resp['data']['total']}, selected ID: {DOCTOR_ID}")
|
||||
else:
|
||||
# Create a test doctor - needs user_id
|
||||
print(f" No doctors found, trying to create...")
|
||||
# List users to get a user_id for doctor
|
||||
time.sleep(1)
|
||||
resp_u, code_u = api_raw("GET", "/users", token=TOKEN)
|
||||
if resp_u.get("success") and resp_u.get("data", {}).get("items"):
|
||||
user_id = resp_u["data"]["items"][0]["id"]
|
||||
test_doctor = {
|
||||
"user_id": user_id,
|
||||
"name": "E2E Test Doctor",
|
||||
"department": "General",
|
||||
"title": "Attending Physician",
|
||||
"specialty": "Internal Medicine",
|
||||
}
|
||||
time.sleep(1)
|
||||
resp2, code2 = api_raw("POST", "/health/doctors", test_doctor, token=TOKEN)
|
||||
if resp2.get("success") and isinstance(resp2.get("data"), dict) and "id" in resp2["data"]:
|
||||
DOCTOR_ID = resp2["data"]["id"]
|
||||
print(f" Created test doctor: {DOCTOR_ID}")
|
||||
else:
|
||||
print(f" [WARN] Failed to create test doctor: HTTP {code2}, {resp2.get('error', resp2.get('message', ''))[:80]}")
|
||||
else:
|
||||
print(f" [WARN] No users to create doctor from")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Part 1: Follow-up Management
|
||||
# ============================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Part 1: Follow-up Management")
|
||||
print("=" * 70)
|
||||
|
||||
# 1.1 Task list
|
||||
print()
|
||||
print("1.1 List follow-up tasks")
|
||||
resp, code = api_raw("GET", "/health/follow-up-tasks", token=TOKEN)
|
||||
record("follow_up", "List follow-up tasks", resp, code)
|
||||
|
||||
# 1.2 Create task
|
||||
print()
|
||||
print("1.2 Create follow-up task")
|
||||
if PATIENT_ID:
|
||||
time.sleep(1)
|
||||
next_week = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
body = {
|
||||
"patient_id": PATIENT_ID,
|
||||
"follow_up_type": "phone",
|
||||
"planned_date": next_week,
|
||||
"content_template": "E2E test follow-up task",
|
||||
}
|
||||
resp, code = api_raw("POST", "/health/follow-up-tasks", body, token=TOKEN)
|
||||
task_data = record("follow_up", "Create follow-up task", resp, code)
|
||||
|
||||
if resp.get("success") and isinstance(task_data, dict) and "id" in task_data:
|
||||
task_id = task_data["id"]
|
||||
task_version = task_data.get("version", 1)
|
||||
print()
|
||||
print("1.2a Get follow-up task detail")
|
||||
resp, code = api_raw("GET", f"/health/follow-up-tasks/{task_id}", token=TOKEN)
|
||||
record("follow_up", "Get follow-up task detail", resp, code)
|
||||
|
||||
print()
|
||||
print("1.2b Update follow-up task")
|
||||
time.sleep(1)
|
||||
update_body = {"content_template": "E2E test updated", "status": "in_progress", "version": task_version}
|
||||
resp, code = api_raw("PUT", f"/health/follow-up-tasks/{task_id}", update_body, token=TOKEN)
|
||||
record("follow_up", "Update follow-up task", resp, code)
|
||||
|
||||
print()
|
||||
print("1.2c Create follow-up record")
|
||||
time.sleep(1)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
record_body = {
|
||||
"task_id": task_id,
|
||||
"executed_date": today,
|
||||
"result": "contacted",
|
||||
"patient_condition": "stable",
|
||||
"medical_advice": "continue medication",
|
||||
}
|
||||
resp, code = api_raw("POST", f"/health/follow-up-tasks/{task_id}/records", record_body, token=TOKEN)
|
||||
record("follow_up", "Create follow-up record", resp, code)
|
||||
else:
|
||||
print(" [SKIP] No patient ID")
|
||||
|
||||
# 1.3 Template list
|
||||
print()
|
||||
print("1.3 List follow-up templates")
|
||||
resp, code = api_raw("GET", "/health/follow-up-templates", token=TOKEN)
|
||||
record("follow_up", "List follow-up templates", resp, code)
|
||||
|
||||
# 1.4 Record list
|
||||
print()
|
||||
print("1.4 List follow-up records")
|
||||
resp, code = api_raw("GET", "/health/follow-up-records", token=TOKEN)
|
||||
record("follow_up", "List follow-up records", resp, code)
|
||||
|
||||
# 1.5 Auth check - no token
|
||||
print()
|
||||
print("1.5 Auth check - no token (expect 401)")
|
||||
resp, code = api_raw("GET", "/health/follow-up-tasks")
|
||||
if code == 401:
|
||||
print(f" [PASS] No token -> 401: HTTP {code}")
|
||||
results["follow_up"].append({
|
||||
"name": "Auth check (no token)", "status_code": code, "success": True,
|
||||
"response_success": resp.get("success"), "error": "", "detail": "401 Unauthorized",
|
||||
})
|
||||
else:
|
||||
print(f" [FAIL] Expected 401, got: HTTP {code}, body={json.dumps(resp)[:100]}")
|
||||
results["follow_up"].append({
|
||||
"name": "Auth check (no token)", "status_code": code, "success": False,
|
||||
"response_success": resp.get("success"), "error": f"Expected 401, got {code}", "detail": "",
|
||||
})
|
||||
|
||||
# 1.6 Validation check - missing required fields
|
||||
print()
|
||||
print("1.6 Validation - missing required fields (expect 400/422)")
|
||||
body_invalid = {"follow_up_type": "phone"}
|
||||
resp, code = api_raw("POST", "/health/follow-up-tasks", body_invalid, token=TOKEN)
|
||||
is_validation_error = code in (400, 422)
|
||||
if is_validation_error:
|
||||
print(f" [PASS] Validation error: HTTP {code}, msg={resp.get('error', resp.get('message', ''))[:80]}")
|
||||
else:
|
||||
print(f" [FAIL] Expected 400/422, got: HTTP {code}, body={json.dumps(resp)[:100]}")
|
||||
results["follow_up"].append({
|
||||
"name": "Validation (missing fields)", "status_code": code, "success": is_validation_error,
|
||||
"response_success": resp.get("success"),
|
||||
"error": resp.get("error", resp.get("message", ""))[:80],
|
||||
"detail": f"Expected 400/422, got {code}",
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Part 2: Consultation Management
|
||||
# ============================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Part 2: Consultation Management")
|
||||
print("=" * 70)
|
||||
|
||||
# 2.1 Session list
|
||||
print()
|
||||
print("2.1 List consultation sessions")
|
||||
resp, code = api_raw("GET", "/health/consultation-sessions", token=TOKEN)
|
||||
record("consultation", "List consultation sessions", resp, code)
|
||||
|
||||
# 2.2 Create session
|
||||
print()
|
||||
print("2.2 Create consultation session")
|
||||
if PATIENT_ID and DOCTOR_ID:
|
||||
time.sleep(1)
|
||||
body = {
|
||||
"patient_id": PATIENT_ID,
|
||||
"doctor_id": DOCTOR_ID,
|
||||
"subject": "E2E test consultation",
|
||||
}
|
||||
resp, code = api_raw("POST", "/health/consultation-sessions", body, token=TOKEN)
|
||||
consult_data = record("consultation", "Create consultation session", resp, code)
|
||||
|
||||
if resp.get("success") and isinstance(consult_data, dict) and "id" in consult_data:
|
||||
consult_id = consult_data["id"]
|
||||
|
||||
print()
|
||||
print("2.2a Get consultation detail")
|
||||
resp, code = api_raw("GET", f"/health/consultation-sessions/{consult_id}", token=TOKEN)
|
||||
record("consultation", "Get consultation detail", resp, code)
|
||||
|
||||
print()
|
||||
print("2.2b Send consultation message")
|
||||
msg_body = {
|
||||
"session_id": consult_id,
|
||||
"content": "E2E test message",
|
||||
"message_type": "text",
|
||||
}
|
||||
resp, code = api_raw("POST", "/health/consultation-messages", msg_body, token=TOKEN)
|
||||
record("consultation", "Send consultation message", resp, code)
|
||||
|
||||
print()
|
||||
print("2.2c List consultation messages")
|
||||
resp, code = api_raw("GET", f"/health/consultation-sessions/{consult_id}/messages", token=TOKEN)
|
||||
record("consultation", "List consultation messages", resp, code)
|
||||
|
||||
print()
|
||||
print("2.2d Close consultation session")
|
||||
resp, code = api_raw("PUT", f"/health/consultation-sessions/{consult_id}/close", token=TOKEN)
|
||||
record("consultation", "Close consultation session", resp, code)
|
||||
|
||||
print()
|
||||
print("2.2e Create follow-up from consultation")
|
||||
fu_body = {
|
||||
"follow_up_type": "phone",
|
||||
"planned_date": next_week if PATIENT_ID else "2026-06-01",
|
||||
"content_template": "Follow-up from E2E consultation",
|
||||
}
|
||||
resp, code = api_raw("POST", f"/health/consultation-sessions/{consult_id}/follow-up", fu_body, token=TOKEN)
|
||||
record("consultation", "Create follow-up from consultation", resp, code)
|
||||
else:
|
||||
print(f" [SKIP] Missing patient or doctor ID")
|
||||
|
||||
# 2.3 Auth check
|
||||
print()
|
||||
print("2.3 Auth check - no token (expect 401)")
|
||||
resp, code = api_raw("GET", "/health/consultation-sessions")
|
||||
if code == 401:
|
||||
print(f" [PASS] No token -> 401: HTTP {code}")
|
||||
results["consultation"].append({
|
||||
"name": "Auth check (no token)", "status_code": code, "success": True,
|
||||
"response_success": resp.get("success"), "error": "", "detail": "401 Unauthorized",
|
||||
})
|
||||
else:
|
||||
print(f" [FAIL] Expected 401, got: HTTP {code}")
|
||||
results["consultation"].append({
|
||||
"name": "Auth check (no token)", "status_code": code, "success": False,
|
||||
"response_success": resp.get("success"), "error": f"Expected 401, got {code}", "detail": "",
|
||||
})
|
||||
|
||||
# 2.4 Doctor dashboard
|
||||
print()
|
||||
print("2.4 Doctor dashboard")
|
||||
resp, code = api_raw("GET", "/health/doctor/dashboard", token=TOKEN)
|
||||
record("consultation", "Doctor dashboard", resp, code)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Part 3: Points / Rewards
|
||||
# ============================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Part 3: Points / Rewards System")
|
||||
print("=" * 70)
|
||||
|
||||
# 3.1 Rules
|
||||
print()
|
||||
print("3.1 List points rules (admin)")
|
||||
resp, code = api_raw("GET", "/health/admin/points/rules", token=TOKEN)
|
||||
record("points", "List points rules", resp, code)
|
||||
|
||||
# 3.2 Products
|
||||
print()
|
||||
print("3.2 List points products (admin)")
|
||||
resp, code = api_raw("GET", "/health/admin/points/products", token=TOKEN)
|
||||
record("points", "List points products (admin)", resp, code)
|
||||
|
||||
# 3.3 Orders
|
||||
print()
|
||||
print("3.3 List points orders (admin)")
|
||||
resp, code = api_raw("GET", "/health/admin/points/orders", token=TOKEN)
|
||||
record("points", "List points orders (admin)", resp, code)
|
||||
|
||||
# 3.4 Account
|
||||
print()
|
||||
print("3.4 Get points account (patient)")
|
||||
resp, code = api_raw("GET", "/health/points/account", token=TOKEN)
|
||||
record("points", "Get points account", resp, code)
|
||||
|
||||
# 3.5 Checkin
|
||||
print()
|
||||
print("3.5 Daily checkin")
|
||||
resp, code = api_raw("POST", "/health/points/checkin", token=TOKEN)
|
||||
is_checkin_ok = resp.get("success") == True or "already" in str(resp.get("error", "")).lower() or "already" in str(resp.get("message", "")).lower()
|
||||
print(f" [{'PASS' if is_checkin_ok else 'INFO'}] Checkin: HTTP {code}, success={resp.get('success')}, error={resp.get('error', resp.get('message', ''))}")
|
||||
results["points"].append({
|
||||
"name": "Daily checkin", "status_code": code, "success": is_checkin_ok,
|
||||
"response_success": resp.get("success"),
|
||||
"error": resp.get("error", resp.get("message", "")),
|
||||
"detail": "Success or already checked in = PASS",
|
||||
})
|
||||
|
||||
# 3.6 Checkin status
|
||||
print()
|
||||
print("3.6 Checkin status")
|
||||
resp, code = api_raw("GET", "/health/points/checkin/status", token=TOKEN)
|
||||
record("points", "Checkin status", resp, code)
|
||||
|
||||
# 3.7 Transactions
|
||||
print()
|
||||
print("3.7 List transactions")
|
||||
resp, code = api_raw("GET", "/health/points/transactions", token=TOKEN)
|
||||
record("points", "List transactions", resp, code)
|
||||
|
||||
# 3.8 Products (patient view)
|
||||
print()
|
||||
print("3.8 List products (patient)")
|
||||
resp, code = api_raw("GET", "/health/points/products", token=TOKEN)
|
||||
record("points", "List products (patient)", resp, code)
|
||||
|
||||
# 3.9 Statistics (admin)
|
||||
print()
|
||||
print("3.9 Points statistics (admin)")
|
||||
resp, code = api_raw("GET", "/health/admin/points/statistics", token=TOKEN)
|
||||
record("points", "Points statistics", resp, code)
|
||||
|
||||
# 3.10 Offline events (patient)
|
||||
print()
|
||||
print("3.10 List offline events (patient)")
|
||||
resp, code = api_raw("GET", "/health/offline-events", token=TOKEN)
|
||||
record("points", "List offline events", resp, code)
|
||||
|
||||
# 3.11 Auth check
|
||||
print()
|
||||
print("3.11 Auth check - no token (expect 401)")
|
||||
resp, code = api_raw("GET", "/health/admin/points/rules")
|
||||
if code == 401:
|
||||
print(f" [PASS] No token -> 401: HTTP {code}")
|
||||
results["points"].append({
|
||||
"name": "Auth check (no token)", "status_code": code, "success": True,
|
||||
"response_success": resp.get("success"), "error": "", "detail": "401 Unauthorized",
|
||||
})
|
||||
else:
|
||||
print(f" [FAIL] Expected 401, got: HTTP {code}")
|
||||
results["points"].append({
|
||||
"name": "Auth check (no token)", "status_code": code, "success": False,
|
||||
"response_success": resp.get("success"), "error": f"Expected 401, got {code}", "detail": "",
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Summary Report
|
||||
# ============================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("SUMMARY REPORT")
|
||||
print("=" * 70)
|
||||
|
||||
total_pass = 0
|
||||
total_fail = 0
|
||||
|
||||
for section_name, section_key in [("Follow-up", "follow_up"), ("Consultation", "consultation"), ("Points/Rewards", "points")]:
|
||||
items = results[section_key]
|
||||
passed = sum(1 for i in items if i["success"])
|
||||
failed = sum(1 for i in items if not i["success"])
|
||||
total_pass += passed
|
||||
total_fail += failed
|
||||
|
||||
print()
|
||||
print(f"--- {section_name} ---")
|
||||
print(f" PASS: {passed}, FAIL: {failed}, Total: {len(items)}")
|
||||
for item in items:
|
||||
icon = "PASS" if item["success"] else "FAIL"
|
||||
detail = f" | {item['detail']}" if item["detail"] else ""
|
||||
print(f" [{icon}] {item['name']}: HTTP {item['status_code']}{detail}")
|
||||
|
||||
total = total_pass + total_fail
|
||||
rate = (total_pass / total * 100) if total > 0 else 0
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(f"TOTAL: {total} tests, PASS {total_pass} ({rate:.1f}%), FAIL {total_fail}")
|
||||
print("=" * 70)
|
||||
|
||||
if total_fail > 0:
|
||||
print()
|
||||
print("FAILED ITEMS:")
|
||||
for section_name, section_key in [("Follow-up", "follow_up"), ("Consultation", "consultation"), ("Points/Rewards", "points")]:
|
||||
for item in results[section_key]:
|
||||
if not item["success"]:
|
||||
print(f" - [{section_name}] {item['name']}: HTTP {item['status_code']}, error={item['error']}")
|
||||
120
scripts/fix-rate-trend.sh
Normal file
120
scripts/fix-rate-trend.sh
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# fix-rate-trend.sh — Fix 率趋势监控
|
||||
#
|
||||
# 分析 git 提交历史,按周统计 fix 率趋势。
|
||||
# Fix 定义: 提交消息以 "fix" 开头(conventional commits)。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/fix-rate-trend.sh # 全量趋势
|
||||
# bash scripts/fix-rate-trend.sh 30 # 最近 30 天
|
||||
#
|
||||
# 目标: fix 率 < 15%(当前 ~23%)
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
DAYS="${1:-999}"
|
||||
SINCE=""
|
||||
if [ "$DAYS" != "999" ]; then
|
||||
SINCE="--since=\"${DAYS} days ago\""
|
||||
fi
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "=========================================="
|
||||
echo " Fix Rate Trend — fix / total commits"
|
||||
if [ "$DAYS" != "999" ]; then
|
||||
echo " (Last ${DAYS} days)"
|
||||
fi
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Collect weekly stats
|
||||
declare -a WEEKS=()
|
||||
declare -a TOTALS=()
|
||||
declare -a FIXES=()
|
||||
declare -a RATES=()
|
||||
|
||||
eval "git log --oneline --format='%ad|%s' --date=short $SINCE" | while IFS='|' read -r date msg; do
|
||||
echo "$date|$msg"
|
||||
done | awk -F'|' '
|
||||
{
|
||||
date = $1
|
||||
msg = $2
|
||||
# Get ISO week number (year-week)
|
||||
cmd = "date -d \"" date "\" +\"%G-W%V\" 2>/dev/null || date -j -f \"%Y-%m-%d\" \"" date "\" +\"%G-W%V\" 2>/dev/null"
|
||||
cmd | getline week
|
||||
close(cmd)
|
||||
|
||||
total[week]++
|
||||
if (msg ~ /^fix[\(:]/ || msg ~ /^fix$/) {
|
||||
fix[week]++
|
||||
} else {
|
||||
fix[week] += 0
|
||||
}
|
||||
}
|
||||
END {
|
||||
n = asorti(total, sorted)
|
||||
for (i = 1; i <= n; i++) {
|
||||
w = sorted[i]
|
||||
f = fix[w] + 0
|
||||
t = total[w]
|
||||
r = (f / t) * 100
|
||||
printf "%s|%d|%d|%.1f\n", w, t, f, r
|
||||
}
|
||||
}' > /tmp/fix-rate-$$.tmp
|
||||
|
||||
# Display results
|
||||
printf "%-12s %6s %6s %8s %s\n" "WEEK" "TOTAL" "FIX" "RATE" "BAR"
|
||||
echo "------------------------------------------------------------"
|
||||
|
||||
ALL_TOTAL=0
|
||||
ALL_FIX=0
|
||||
|
||||
while IFS='|' read -r week total fix rate; do
|
||||
ALL_TOTAL=$((ALL_TOTAL + total))
|
||||
ALL_FIX=$((ALL_FIX + fix))
|
||||
|
||||
# Color code by rate
|
||||
bar_len=$(echo "$rate" | awk '{printf "%d", $1 / 5}')
|
||||
bar=""
|
||||
for ((i=0; i<bar_len; i++)); do bar="${bar}#"; done
|
||||
|
||||
if awk "BEGIN { exit ($rate < 15) ? 1 : 0 }"; then
|
||||
if awk "BEGIN { exit ($rate < 25) ? 1 : 0 }"; then
|
||||
color=$YELLOW
|
||||
else
|
||||
color=$RED
|
||||
fi
|
||||
else
|
||||
color=$GREEN
|
||||
fi
|
||||
|
||||
printf "%-12s %6d %6d " "$week" "$total" "$fix"
|
||||
echo -e "${color}${rate}%${NC} ${bar}"
|
||||
done < /tmp/fix-rate-$$.tmp
|
||||
|
||||
rm -f /tmp/fix-rate-$$.tmp
|
||||
|
||||
echo "------------------------------------------------------------"
|
||||
ALL_RATE=$(awk "BEGIN { printf \"%.1f\", ($ALL_FIX / $ALL_TOTAL) * 100 }")
|
||||
echo ""
|
||||
echo -e "Total: ${ALL_TOTAL} commits, ${ALL_FIX} fixes, rate: ${ALL_RATE}%"
|
||||
echo ""
|
||||
|
||||
# Category breakdown for the most recent period
|
||||
echo "=========================================="
|
||||
echo " Fix Category Breakdown (recent)"
|
||||
echo "=========================================="
|
||||
|
||||
eval "git log --oneline --format='%s' $SINCE" | grep -iE "^fix" | \
|
||||
sed -E 's/^fix\([^)]*\):? ?//' | \
|
||||
grep -oE '(permission|auth|安全|security|tenant|CORS|SQL|XSS|前端|后端|接口|字段|API|路由|route|menu|菜单|编译|compile|类型|type|migration|迁移|race|竞态|token|event|事件|login|登录|miniprogram|小程序|Taro|media|轮播|banner)' | \
|
||||
sort | uniq -c | sort -rn | head -15
|
||||
|
||||
echo ""
|
||||
echo "Target: fix rate < 15%"
|
||||
echo "Red line: fix rate > 20% triggers review (see CLAUDE.md)"
|
||||
155
scripts/gen-permissions.js
Normal file
155
scripts/gen-permissions.js
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
// gen-permissions.js — 从 permissions.yaml 生成 seed SQL 和前端 routeConfig 片段
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/gen-permissions.js --sql 输出 seed INSERT SQL
|
||||
// node scripts/gen-permissions.js --frontend 输出 routeConfig.ts 条目
|
||||
// node scripts/gen-permissions.js --validate 验证与代码一致性
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const YAML_PATH = path.resolve(__dirname, '..', 'permissions.yaml');
|
||||
|
||||
function loadPermissions() {
|
||||
const raw = fs.readFileSync(YAML_PATH, 'utf-8');
|
||||
return yaml.load(raw);
|
||||
}
|
||||
|
||||
function getAllCodes(registry) {
|
||||
const codes = [];
|
||||
for (const [, group] of Object.entries(registry)) {
|
||||
for (const perm of group.permissions) {
|
||||
codes.push(perm.code);
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
function generateSeedSQL(registry) {
|
||||
const sys = '00000000-0000-0000-0000-000000000000';
|
||||
const lines = [
|
||||
'-- Auto-generated from permissions.yaml by gen-permissions.js',
|
||||
`-- Generated: ${new Date().toISOString().slice(0, 10)}`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const [groupKey, group] of Object.entries(registry)) {
|
||||
lines.push(`-- ${groupKey}: ${group.description}`);
|
||||
for (const perm of group.permissions) {
|
||||
const parts = perm.code.split('.');
|
||||
const resource = parts.length >= 2 ? parts[0] : groupKey;
|
||||
const action = parts.slice(1).join('.');
|
||||
lines.push(
|
||||
`INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,` +
|
||||
` created_at, updated_at, created_by, updated_by, deleted_at, version)` +
|
||||
` SELECT gen_random_uuid(), t.id, '${perm.code}', '${perm.name}', '${resource}', '${action}', '${perm.name}',` +
|
||||
` NOW(), NOW(), '${sys}', '${sys}', NULL, 1` +
|
||||
` FROM tenant t` +
|
||||
` WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.code = '${perm.code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL)` +
|
||||
` ON CONFLICT DO NOTHING;`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function generateFrontendSnippet(registry) {
|
||||
const lines = ['// Auto-generated from permissions.yaml by gen-permissions.js', ''];
|
||||
|
||||
for (const [, group] of Object.entries(registry)) {
|
||||
const frozenPerms = group.permissions.filter(p => p.frozen);
|
||||
const activePerms = group.permissions.filter(p => !p.frozen);
|
||||
|
||||
// Group by entity prefix (e.g., health.patient → {list, manage})
|
||||
const entities = {};
|
||||
for (const perm of [...activePerms, ...frozenPerms]) {
|
||||
const parts = perm.code.split('.');
|
||||
if (parts.length < 2) continue;
|
||||
const entity = parts.slice(0, -1).join('.');
|
||||
const action = parts[parts.length - 1];
|
||||
if (!entities[entity]) entities[entity] = { list: [], frozen: perm.frozen || false };
|
||||
entities[entity].list.push(perm.code);
|
||||
}
|
||||
|
||||
for (const [entity, info] of Object.entries(entities)) {
|
||||
const frozenAttr = info.frozen ? ',\n frozen: true' : '';
|
||||
lines.push(` {`);
|
||||
lines.push(` path: "/${entity.replace(/\./g, '/')}",`);
|
||||
lines.push(` permissions: [${info.list.map(c => `"${c}"`).join(', ')}]${frozenAttr}`);
|
||||
lines.push(` },`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function validate(registry) {
|
||||
const yamlCodes = getAllCodes(registry);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
// Recursively find .rs files and extract permission codes
|
||||
const backendCodes = new Set();
|
||||
function walkDir(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'target') {
|
||||
walkDir(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.rs')) {
|
||||
const content = fs.readFileSync(full, 'utf-8');
|
||||
// Match code: "xxx.yyy.zzz" pattern in PermissionDescriptor
|
||||
// Must be lowercase letters/digits/hyphens with dots (permission code pattern)
|
||||
const matches = content.matchAll(/code:\s*"([a-z][a-z0-9-]*\.[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*)"/g);
|
||||
for (const m of matches) {
|
||||
backendCodes.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walkDir(path.join(rootDir, 'crates'));
|
||||
|
||||
let errors = 0;
|
||||
|
||||
// Check YAML covers all backend codes
|
||||
for (const code of backendCodes) {
|
||||
if (!yamlCodes.includes(code)) {
|
||||
console.log(`MISSING: Backend '${code}' not in permissions.yaml`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check backend covers all YAML codes
|
||||
for (const code of yamlCodes) {
|
||||
if (!backendCodes.has(code)) {
|
||||
console.log(`MISSING: YAML '${code}' not in backend module.rs`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors === 0) {
|
||||
console.log(`OK: ${yamlCodes.length} YAML / ${backendCodes.size} backend — 0 mismatches`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Main
|
||||
const args = process.argv.slice(2);
|
||||
const registry = loadPermissions();
|
||||
|
||||
if (args.includes('--sql')) {
|
||||
console.log(generateSeedSQL(registry));
|
||||
} else if (args.includes('--frontend')) {
|
||||
console.log(generateFrontendSnippet(registry));
|
||||
} else if (args.includes('--validate')) {
|
||||
const errors = validate(registry);
|
||||
process.exit(errors > 0 ? 1 : 0);
|
||||
} else {
|
||||
const codes = getAllCodes(registry);
|
||||
console.log(`permissions.yaml loaded: ${codes.length} permission codes across ${Object.keys(registry).length} modules`);
|
||||
console.log('Usage:');
|
||||
console.log(' node scripts/gen-permissions.js --sql Generate seed SQL');
|
||||
console.log(' node scripts/gen-permissions.js --frontend Generate routeConfig snippet');
|
||||
console.log(' node scripts/gen-permissions.js --validate Validate consistency');
|
||||
}
|
||||
261
scripts/seed-dialysis-articles.mjs
Normal file
261
scripts/seed-dialysis-articles.mjs
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 血透测试文章种子脚本
|
||||
* 用法: node scripts/seed-dialysis-articles.mjs
|
||||
* 删除现有文章,创建 4 篇血透相关的富文本文章(published 状态)
|
||||
*/
|
||||
|
||||
const BASE = 'http://localhost:3000/api/v1';
|
||||
const LOGIN = { username: 'admin', password: 'Admin@2026' };
|
||||
|
||||
const W = 'data-w-e-type="styled-block"';
|
||||
const P = '#C4623A'; // primary
|
||||
const PL = '#F0DDD4'; // primaryLight
|
||||
|
||||
function applyColor(html) {
|
||||
return html.replaceAll('{{primary}}', P).replaceAll('{{primaryLight}}', PL);
|
||||
}
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: '血透患者日常饮食管理指南',
|
||||
summary: '科学的饮食管理是维持性血液透析患者保持良好营养状态、减少并发症的关键。本文详细介绍了血透患者的饮食原则、各类营养素的摄入建议以及实用的饮食搭配技巧。',
|
||||
sort_order: 1,
|
||||
content: [
|
||||
`<p>血液透析患者在治疗过程中,合理的饮食管理不仅有助于维持良好的营养状态,还能有效减少透析并发症的发生。以下是一份全面的饮食管理指南,帮助您更好地控制饮食。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; padding: 12px 0 0; border-top: 4px solid ${P}; font-size: 20px; font-weight: 700; color: #1a1a1a;">一、蛋白质摄入管理</div>`),
|
||||
|
||||
`<p>透析患者需要适量增加蛋白质摄入,以弥补透析过程中的蛋白质丢失。推荐每日蛋白质摄入量为 <strong>1.0-1.2 g/kg体重</strong>,其中优质蛋白质应占50%以上。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="display: flex; gap: 12px; margin: 16px 0; flex-wrap: wrap;"><div style="flex: 1; min-width: 120px; background: ${PL}; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: ${P};">1.2g</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">每公斤体重/天</div></div><div style="flex: 1; min-width: 120px; background: #f3f4f6; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: #1a1a1a;">50%</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">优质蛋白占比</div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #166534;"><strong>✓ 推荐食物:</strong>鸡蛋清、鱼肉、瘦肉、牛奶、豆腐等优质蛋白来源。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #991b1b;"><strong>✕ 限制食物:</strong>减少植物蛋白(如豆类制品过量)摄入,避免高嘌呤食物。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; height: 2px; margin: 24px 0; background: linear-gradient(90deg, transparent, ${P}, transparent);"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; padding: 12px 0 0; border-top: 4px solid ${P}; font-size: 20px; font-weight: 700; color: #1a1a1a;">二、钾的控制</div>`),
|
||||
|
||||
`<p>高钾血症是透析患者常见的危及生命的并发症。透析间期血钾应控制在 <strong>3.5-5.5 mmol/L</strong>。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0; background: #f9fafb; border-radius: 8px; overflow: hidden; font-size: 14px;"><div style="display: flex; padding: 10px 16px; border-bottom: 1px solid #e5e7eb;"><div style="width: 100px; color: #5a554f; flex-shrink: 0;">检查项目</div><div style="flex: 1; font-weight: 600; color: #1a1a1a;">目标范围</div></div><div style="display: flex; padding: 10px 16px; border-bottom: 1px solid #e5e7eb; background: #fff;"><div style="width: 100px; color: #5a554f; flex-shrink: 0;">血钾</div><div style="flex: 1; font-weight: 600; color: ${P};">3.5-5.5 mmol/L</div></div><div style="display: flex; padding: 10px 16px; border-bottom: 1px solid #e5e7eb;"><div style="width: 100px; color: #5a554f; flex-shrink: 0;">血磷</div><div style="flex: 1; font-weight: 600; color: ${P};">0.87-1.45 mmol/L</div></div><div style="display: flex; padding: 10px 16px; background: #fff;"><div style="width: 100px; color: #5a554f; flex-shrink: 0;">血钙</div><div style="flex: 1; font-weight: 600; color: #1a1a1a;">2.1-2.6 mmol/L</div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #92400e;"><strong>⚠ 注意事项:</strong>高钾水果(香蕉、橙子、猕猴桃)需严格控制摄入量。蔬菜可通过水煮去钾处理后再食用。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; height: 2px; margin: 24px 0; background: linear-gradient(90deg, transparent, ${P}, transparent);"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; padding: 12px 0 0; border-top: 4px solid ${P}; font-size: 20px; font-weight: 700; color: #1a1a1a;">三、水分控制</div>`),
|
||||
|
||||
`<p>透析间期体重增长应控制在干体重的 <strong>3%-5%</strong> 以内。每日液体摄入量一般为前一日尿量加 500ml。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0;"><div style="display: flex; align-items: flex-start; margin-bottom: 16px;"><div style="width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-right: 12px;">1</div><div style="flex: 1; padding-top: 4px;"><div style="font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">记录每日体重</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">每天早上空腹称重,记录体重变化趋势</div></div></div><div style="display: flex; align-items: flex-start; margin-bottom: 16px;"><div style="width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-right: 12px;">2</div><div style="flex: 1; padding-top: 4px;"><div style="font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">控制液体摄入</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">使用有刻度的水杯,做到心中有数</div></div></div><div style="display: flex; align-items: flex-start;"><div style="width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-right: 12px;">3</div><div style="flex: 1; padding-top: 4px;"><div style="font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">减少隐形水分</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">粥、汤、水果中的水分也需计入总量</div></div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 20px 0; padding: 20px; background: linear-gradient(135deg, ${PL}, #f9fafb); border-radius: 12px; position: relative;"><div style="font-size: 36px; color: ${P}; opacity: 0.3; line-height: 1; font-family: Georgia, serif;">"</div><div style="font-size: 15px; color: #3a3a3c; line-height: 1.8; margin-top: -8px; font-style: italic;">透析间期体重增长不超过干体重的5%,是减少心血管并发症的重要措施。</div><div style="margin-top: 10px; font-size: 13px; color: #5a554f; font-weight: 500;">—— KDIGO 指南建议</div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; height: 2px; margin: 24px 0; background: linear-gradient(90deg, transparent, ${P}, transparent);"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; padding: 12px 0 0; border-top: 4px solid ${P}; font-size: 20px; font-weight: 700; color: #1a1a1a;">四、磷的管理</div>`),
|
||||
|
||||
`<p>高磷血症可导致继发性甲状旁腺功能亢进和血管钙化。每日磷摄入量应控制在 <strong>800-1000mg</strong>,并遵医嘱服用磷结合剂。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="display: flex; gap: 12px; margin: 16px 0; flex-wrap: wrap;"><div style="flex: 1; min-width: 140px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px;"><div style="font-size: 15px; font-weight: 700; color: #166534; margin-bottom: 8px;">✓ 低磷食物</div><div style="font-size: 14px; color: #166534; line-height: 1.8;">鸡蛋清<br/>冬瓜、黄瓜<br/>米饭、面条<br/>苹果、梨</div></div><div style="flex: 1; min-width: 140px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 14px;"><div style="font-size: 15px; font-weight: 700; color: #991b1b; margin-bottom: 8px;">✕ 高磷食物</div><div style="font-size: 14px; color: #991b1b; line-height: 1.8;">动物内脏<br/>坚果、芝麻<br/>碳酸饮料<br/>加工肉制品</div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: ${PL}; border: 1px solid ${P}; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #1a1a1a;"><strong style="color: ${P};">★ 温馨提示:</strong>以上饮食建议仅供参考,具体饮食方案请遵医嘱。如有不适请及时联系您的主治医生。</div>`),
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
title: '血液透析治疗流程全解析',
|
||||
summary: '了解血液透析的完整治疗流程,从透析前准备到透析后护理,帮助患者和家属全面认识透析治疗过程,减少焦虑,提高治疗依从性。',
|
||||
sort_order: 2,
|
||||
content: [
|
||||
`<p>血液透析(Hemodialysis, HD)是目前治疗急慢性肾衰竭最常用的肾脏替代治疗方法之一。本文将详细解析一次完整的血液透析治疗流程。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="text-align: center; margin: 24px 0 12px; padding: 10px 0; border-top: 2px solid ${P}; border-bottom: 2px solid ${P}; font-size: 20px; font-weight: 700; color: #1a1a1a;">透析治疗完整流程</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0; padding-left: 20px; border-left: 2px solid ${P};"><div style="position: relative; padding: 0 0 20px 16px;"><div style="position: absolute; left: -27px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: ${P};"></div><div style="font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">透析前一天</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">控制水分摄入,测量体重,准备透析用品</div></div><div style="position: relative; padding: 0 0 20px 16px;"><div style="position: absolute; left: -27px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: ${P};"></div><div style="font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">透析当天 · 到达前</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">穿宽松衣物,带好透析卡和药物,测量体重</div></div><div style="position: relative; padding: 0 0 20px 16px;"><div style="position: absolute; left: -27px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: ${P};"></div><div style="font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">透析开始</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">血管穿刺/连接,血流量调节,参数设置</div></div><div style="position: relative; padding: 0 0 20px 16px;"><div style="position: absolute; left: -27px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: ${P};"></div><div style="font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">透析进行中(4小时)</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">持续监测血压、心率,注意有无不适</div></div><div style="position: relative; padding: 0 0 20px 16px;"><div style="position: absolute; left: -27px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: ${P};"></div><div style="font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">透析结束</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">回血、拔针、压迫止血、测量体重</div></div><div style="position: relative; padding: 0 0 0 16px;"><div style="position: absolute; left: -27px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: ${P};"></div><div style="font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">透析后观察</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">休息15-30分钟,确认无头晕出血后再离开</div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; height: 2px; margin: 24px 0; background: linear-gradient(90deg, transparent, ${P}, transparent);"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border-left: 4px solid ${P}; padding-left: 12px; font-size: 20px; font-weight: 700; color: #1a1a1a; margin: 24px 0 12px;">透析参数与监测</div>`),
|
||||
|
||||
`<p>标准的血液透析治疗通常每次持续 <strong>4小时</strong>,每周进行 <strong>3次</strong>。治疗过程中需要持续监测多项参数。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0; font-size: 14px;"><div style="background: ${PL}; padding: 10px 12px; font-weight: 600; color: ${P}; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">参数</div><div style="background: ${PL}; padding: 10px 12px; font-weight: 600; color: ${P}; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">标准值</div><div style="background: ${PL}; padding: 10px 12px; font-weight: 600; color: ${P}; border-bottom: 1px solid #e5e7eb;">说明</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">血流量</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">200-300 ml/min</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">因人而异</div><div style="background: #f9fafb; padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">透析液流量</div><div style="background: #f9fafb; padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">500 ml/min</div><div style="background: #f9fafb; padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">标准设置</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">超滤量</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">个体化制定</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">根据体重增长</div><div style="background: #f9fafb; padding: 10px 12px; border-right: 1px solid #e5e7eb;">治疗时间</div><div style="background: #f9fafb; padding: 10px 12px; border-right: 1px solid #e5e7eb;">4 小时</div><div style="background: #f9fafb; padding: 10px 12px;">每次标准</div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #1e40af;"><strong>ℹ 补充说明:</strong>透析充分性评估通常使用 Kt/V 值,目标值应 ≥ 1.2。您的医生会定期检查此项指标。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0; background: #f9fafb; border-radius: 8px; overflow: hidden;"><div style="background: ${PL}; padding: 10px 16px; font-size: 15px; font-weight: 600; color: ${P};">Q:透析过程中可以进食吗?</div><div style="padding: 12px 16px; font-size: 14px; line-height: 1.8; color: #3a3a3c;">A:可以少量进食,但建议选择易消化的食物。避免过饱进食,以防低血压。透析中进食还可以预防低血糖的发生。</div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0; background: #f9fafb; border-radius: 8px; overflow: hidden;"><div style="background: ${PL}; padding: 10px 16px; font-size: 15px; font-weight: 600; color: ${P};">Q:透析后为什么会感到疲乏?</div><div style="padding: 12px 16px; font-size: 14px; line-height: 1.8; color: #3a3a3c;">A:透析后疲乏是常见现象,主要与体液变化、电解质调整和代谢废物清除有关。建议透析后适当休息,保证充足睡眠。</div></div>`),
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
title: '血透患者血管通路的护理要点',
|
||||
summary: '血管通路被称为血透患者的"生命线",正确的护理至关重要。本文介绍动静脉内瘘和中心静脉导管的日常护理方法,帮助您延长通路使用寿命。',
|
||||
sort_order: 3,
|
||||
content: [
|
||||
`<p>血管通路是血液透析治疗的基础,良好的血管通路是保证透析质量和患者安全的前提。保护好您的"生命线",从日常护理做起。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="display: inline-block; background: ${PL}; color: ${P}; padding: 4px 14px; border-radius: 4px; font-size: 18px; font-weight: 700; margin: 24px 0 12px;">动静脉内瘘(AVF)护理</div>`),
|
||||
|
||||
`<p>动静脉内瘘是目前最理想的长期血管通路,平均使用寿命可达 <strong>5-10年</strong>。正确的日常护理能显著延长内瘘的使用寿命。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0;"><div style="display: flex; align-items: flex-start; margin-bottom: 16px;"><div style="width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-right: 12px;">1</div><div style="flex: 1; padding-top: 4px;"><div style="font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">每日自检</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">每天触摸内瘘部位,感受震颤("嗡嗡"感),如震颤消失需立即就医</div></div></div><div style="display: flex; align-items: flex-start; margin-bottom: 16px;"><div style="width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-right: 12px;">2</div><div style="flex: 1; padding-top: 4px;"><div style="font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">避免压迫</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">不在内瘘侧手臂测量血压、抽血、输液或佩戴过紧饰品</div></div></div><div style="display: flex; align-items: flex-start; margin-bottom: 16px;"><div style="width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-right: 12px;">3</div><div style="flex: 1; padding-top: 4px;"><div style="font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">适当锻炼</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">术后2-4周开始握球训练,促进内瘘成熟</div></div></div><div style="display: flex; align-items: flex-start;"><div style="width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-right: 12px;">4</div><div style="flex: 1; padding-top: 4px;"><div style="font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 2px;">保持清洁</div><div style="font-size: 14px; color: #5a554f; line-height: 1.6;">透析前清洗内瘘侧手臂,保持穿刺部位清洁干燥</div></div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #991b1b;"><strong>✕ 严禁事项:</strong>内瘘侧手臂避免提重物(>5kg)、避免侧卧压迫、避免接触锐器。如发现震颤减弱或消失、局部红肿热痛,应立即就医。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; height: 2px; margin: 24px 0; background: linear-gradient(90deg, transparent, ${P}, transparent);"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="display: inline-block; background: ${PL}; color: ${P}; padding: 4px 14px; border-radius: 4px; font-size: 18px; font-weight: 700; margin: 24px 0 12px;">中心静脉导管(CVC)护理</div>`),
|
||||
|
||||
`<p>中心静脉导管通常用于内瘘成熟前的过渡期或无法建立内瘘的患者。导管护理的核心是 <strong>预防感染</strong> 和 <strong>保持通畅</strong>。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="display: flex; gap: 12px; margin: 16px 0; flex-wrap: wrap;"><div style="flex: 1; min-width: 120px; background: ${PL}; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: ${P};">100%</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">保持敷料干燥</div></div><div style="flex: 1; min-width: 120px; background: #f3f4f6; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: #1a1a1a;">2次</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">每周换药频率</div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #92400e;"><strong>⚠ 注意事项:</strong>洗澡时必须用防水贴膜覆盖导管出口处。禁止在导管附近使用剪刀等锐器。如导管不慎脱出,立即压迫止血并急诊就医。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0;"><div style="display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px;"><span style="color: #1a1a1a; font-weight: 600;">内瘘成熟度</span><span style="color: ${P}; font-weight: 700;">良好</span></div><div style="background: #e5e7eb; border-radius: 99px; height: 8px; overflow: hidden;"><div style="background: ${P}; height: 100%; width: 85%; border-radius: 99px;"></div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: ${PL}; border: 1px solid ${P}; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #1a1a1a;"><strong style="color: ${P};">★ 就医指征:</strong>以下情况需立即就医:① 震颤消失 ② 穿刺点持续出血 ③ 导管出口红肿渗液 ④ 内瘘部位出现搏动性包块 ⑤ 体温超过38°C</div>`),
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
title: '血液透析常见并发症及应对策略',
|
||||
summary: '了解血液透析过程中可能出现的低血压、肌肉痉挛、失衡综合征等常见并发症的表现形式和应对方法,做到早识别、早处理。',
|
||||
sort_order: 4,
|
||||
content: [
|
||||
`<p>虽然血液透析是安全有效的治疗方式,但治疗过程中和治疗后可能出现一些并发症。了解这些并发症的表现和应对方法,有助于患者更好地配合治疗。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; display: flex; align-items: center; gap: 10px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; font-size: 14px; font-weight: 700; flex-shrink: 0;">1</span><span style="font-size: 18px; font-weight: 700; color: #1a1a1a;">透析低血压</span></div>`),
|
||||
|
||||
`<p>透析低血压是最常见的急性并发症,发生率约 <strong>20%-30%</strong>。表现为头晕、恶心、出冷汗、肌肉痉挛等。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="display: flex; gap: 12px; margin: 16px 0; flex-wrap: wrap;"><div style="flex: 1; min-width: 120px; background: ${PL}; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: ${P};">25%</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">发生率</div></div><div style="flex: 1; min-width: 120px; background: #f3f4f6; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: #1a1a1a;">90/60</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">诊断标准 (mmHg)</div></div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #166534;"><strong>✓ 应对方法:</strong>立即降低超滤速度,采取头低脚高位,必要时补充生理盐水100-200ml。平时注意控制透析间期体重增长。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; border-top: 1px dashed #d1d5db; margin: 24px 0; height: 0;"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; display: flex; align-items: center; gap: 10px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; font-size: 14px; font-weight: 700; flex-shrink: 0;">2</span><span style="font-size: 18px; font-weight: 700; color: #1a1a1a;">肌肉痉挛</span></div>`),
|
||||
|
||||
`<p>多发生于透析后半程,以小腿和足部肌肉痉挛最常见,与超滤过快、低血压、低钠血症等因素有关。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #166534;"><strong>✓ 应对方法:</strong>减慢超滤速度,局部按摩热敷,补充高渗盐水或葡萄糖。日常补充维生素E和左旋卡尼汀可能有预防作用。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; border-top: 1px dashed #d1d5db; margin: 24px 0; height: 0;"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; display: flex; align-items: center; gap: 10px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; font-size: 14px; font-weight: 700; flex-shrink: 0;">3</span><span style="font-size: 18px; font-weight: 700; color: #1a1a1a;">透析失衡综合征</span></div>`),
|
||||
|
||||
`<p>主要见于首次透析或透析间隔过长的患者,由于血中尿素氮等溶质快速清除,导致脑组织与血液之间产生渗透压差。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #1e40af;"><strong>ℹ 临床表现:</strong>轻度表现为头痛、恶心、呕吐、烦躁不安;重度可出现意识障碍、抽搐甚至昏迷。预防关键是首次透析采用低血流量、短时间(2小时)的诱导透析方案。</div>`),
|
||||
|
||||
applyColor(`<div ${W} style="border: none; border-top: 1px dashed #d1d5db; margin: 24px 0; height: 0;"></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 24px 0 12px; display: flex; align-items: center; gap: 10px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: ${P}; color: #fff; border-radius: 50%; font-size: 14px; font-weight: 700; flex-shrink: 0;">4</span><span style="font-size: 18px; font-weight: 700; color: #1a1a1a;">透析相关发热</span></div>`),
|
||||
|
||||
`<p>透析过程中或透析结束后出现的体温升高,需区分感染性和非感染性发热。</p>`,
|
||||
|
||||
applyColor(`<div ${W} style="display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0; font-size: 14px;"><div style="background: ${PL}; padding: 10px 12px; font-weight: 600; color: ${P}; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">类型</div><div style="background: ${PL}; padding: 10px 12px; font-weight: 600; color: ${P}; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">特点</div><div style="background: ${PL}; padding: 10px 12px; font-weight: 600; color: ${P}; border-bottom: 1px solid #e5e7eb;">处理</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">致热原反应</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">透析开始1-2h</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">对症处理</div><div style="background: #f9fafb; padding: 10px 12px; border-right: 1px solid #e5e7eb;">感染性</div><div style="background: #f9fafb; padding: 10px 12px; border-right: 1px solid #e5e7eb;">透析结束后</div><div style="background: #f9fafb; padding: 10px 12px;">抗感染治疗</div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="margin: 16px 0; background: #f9fafb; border-radius: 8px; overflow: hidden;"><div style="background: ${PL}; padding: 10px 16px; font-size: 15px; font-weight: 600; color: ${P};">Q:透析中出现不适应该怎么办?</div><div style="padding: 12px 16px; font-size: 14px; line-height: 1.8; color: #3a3a3c;">A:立即告知医护人员,不要自行忍耐。医护人员会根据症状严重程度及时调整透析参数或给予对症处理。</div></div>`),
|
||||
|
||||
applyColor(`<div ${W} style="background: ${PL}; border: 1px solid ${P}; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #1a1a1a;"><strong style="color: ${P};">★ 紧急就医:</strong>如出现严重头痛伴呕吐、持续胸痛、呼吸困难、意识模糊、内瘘震颤消失、导管出口大量渗血渗液等严重情况,请立即拨打120或前往最近医院急诊。</div>`),
|
||||
].join(''),
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🔑 登录获取 Token...');
|
||||
const loginRes = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(LOGIN),
|
||||
});
|
||||
const loginData = await loginRes.json();
|
||||
const token = loginData.data.access_token;
|
||||
if (!token) { console.error('登录失败:', loginData); process.exit(1); }
|
||||
console.log('✅ 登录成功');
|
||||
|
||||
const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
|
||||
// 删除现有文章
|
||||
console.log('\n📋 获取现有文章...');
|
||||
const listRes = await fetch(`${BASE}/health/articles?page=1&page_size=100`, { headers });
|
||||
const listData = await listRes.json();
|
||||
const existingArticles = listData.data?.items || [];
|
||||
console.log(` 找到 ${existingArticles.length} 篇现有文章`);
|
||||
|
||||
for (const article of existingArticles) {
|
||||
await fetch(`${BASE}/health/articles/${article.id}`, { method: 'DELETE', headers });
|
||||
console.log(` 🗑 删除: ${article.title}`);
|
||||
}
|
||||
|
||||
// 获取分类和标签
|
||||
const [catsRes, tagsRes] = await Promise.all([
|
||||
fetch(`${BASE}/health/article-categories`, { headers }),
|
||||
fetch(`${BASE}/health/article-tags`, { headers }),
|
||||
]);
|
||||
const cats = (await catsRes.json()).data || [];
|
||||
const tags = (await tagsRes.json()).data || [];
|
||||
console.log(`\n📂 分类: ${cats.map((c) => c.name).join(', ') || '无'}`);
|
||||
console.log(`🏷 标签: ${tags.map((t) => t.name).join(', ') || '无'}`);
|
||||
|
||||
// 创建文章
|
||||
console.log('\n📝 创建血透测试文章...');
|
||||
for (const article of articles) {
|
||||
const createRes = await fetch(`${BASE}/health/articles`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
content: article.content,
|
||||
content_type: 'rich_text',
|
||||
sort_order: article.sort_order,
|
||||
category_id: cats[0]?.id,
|
||||
tag_ids: tags.slice(0, 2).map((t) => t.id),
|
||||
}),
|
||||
});
|
||||
const created = await createRes.json();
|
||||
if (!created.success) {
|
||||
console.error(` ❌ 创建失败: ${article.title}`, created.message);
|
||||
continue;
|
||||
}
|
||||
const articleId = created.data.id;
|
||||
const version = created.data.version;
|
||||
console.log(` ✅ 创建: ${article.title} (${articleId.slice(0, 8)}...)`);
|
||||
|
||||
// 提交审核
|
||||
const submitRes = await fetch(`${BASE}/health/articles/${articleId}/submit`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ version }),
|
||||
});
|
||||
const submitted = await submitRes.json();
|
||||
if (submitted.success) {
|
||||
const newVersion = submitted.data.version;
|
||||
console.log(` 📤 提交审核成功 (v${newVersion})`);
|
||||
|
||||
// 审核通过
|
||||
const approveRes = await fetch(`${BASE}/health/articles/${articleId}/approve`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ version: newVersion }),
|
||||
});
|
||||
const approved = await approveRes.json();
|
||||
if (approved.success) {
|
||||
console.log(` ✅ 审核通过,已发布`);
|
||||
} else {
|
||||
console.error(` ❌ 审核失败:`, approved.message);
|
||||
}
|
||||
} else {
|
||||
console.error(` ❌ 提交失败:`, submitted.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 完成!4 篇血透测试文章已创建并发布。');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('脚本执行失败:', e); process.exit(1); });
|
||||
Reference in New Issue
Block a user