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:
iven
2026-06-13 00:32:50 +08:00
commit 3772afd987
438 changed files with 86511 additions and 0 deletions

394
scripts/api_test.sh Normal file
View 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
View 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
View 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

View 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
View 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
-- ============================================================
-- 化验单 13 个月前,肌酐 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;
-- 化验单 21 个月前,肌酐 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';

View 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)

View 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
View 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
View 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');
}

View 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>内瘘侧手臂避免提重物(&gt;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); });