From b0f96258ee61d3fe2622caeaf3b0de88eadf23fc Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 21 May 2026 17:53:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(health+server):=20=E5=A4=9A=E4=B8=93?= =?UTF-8?q?=E5=AE=B6=E7=BB=84=E7=94=9F=E4=BA=A7=E5=B0=B1=E7=BB=AA=E5=BA=A6?= =?UTF-8?q?=E5=88=86=E6=9E=90=20=E2=80=94=20DTO=20=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=20+=20=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 五维度分析结果(DevOps 4.0/10, 医疗合规 9C/6P/1NC, 前端 Lighthouse 94/100/100): 1. Article/Category/Tag DTO 补全 #[derive(Validate)] + handler .validate() 调用(6 DTO + 8 handler) 2. 审计日志 API 新增 user_name 字段(批量关联 users 表),仪表盘显示用户名而非 UUID 3. 多专家组分析报告存档 docs/discussions/ --- crates/erp-health/src/dto/article_dto.rs | 34 +++- .../src/handler/article_category_handler.rs | 8 + .../erp-health/src/handler/article_handler.rs | 14 ++ .../src/handler/article_tag_handler.rs | 8 + crates/erp-server/src/handlers/audit_log.rs | 92 ++++++++- ...-expert-production-readiness-brainstorm.md | 176 ++++++++++++++++++ 6 files changed, 318 insertions(+), 14 deletions(-) create mode 100644 docs/discussions/2026-05-21-multi-expert-production-readiness-brainstorm.md diff --git a/crates/erp-health/src/dto/article_dto.rs b/crates/erp-health/src/dto/article_dto.rs index de07383..cec66ed 100644 --- a/crates/erp-health/src/dto/article_dto.rs +++ b/crates/erp-health/src/dto/article_dto.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; +use validator::Validate; use erp_core::sanitize::{ sanitize_option, sanitize_rich_html_option, sanitize_string, strip_html_tags, @@ -72,16 +73,23 @@ pub struct ArticleListParams { pub keyword: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct CreateArticleReq { + #[validate(length(min = 1, max = 500, message = "文章标题长度须在1-500之间"))] pub title: String, + #[validate(length(max = 2000, message = "摘要最多2000字"))] pub summary: Option, pub content: Option, + #[validate(length(max = 500, message = "封面URL最多500字"))] pub cover_image: Option, + #[validate(length(max = 100, message = "分类名最多100字"))] pub category: Option, + #[validate(length(max = 100, message = "作者名最多100字"))] pub author: Option, pub published_at: Option>, + #[validate(length(max = 200, message = "slug最多200字"))] pub slug: Option, + #[validate(length(max = 50, message = "内容类型最多50字"))] pub content_type: Option, /// 分类 ID pub category_id: Option, @@ -103,16 +111,23 @@ impl CreateArticleReq { } } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct UpdateArticleReq { + #[validate(length(min = 1, max = 500, message = "文章标题长度须在1-500之间"))] pub title: Option, + #[validate(length(max = 2000, message = "摘要最多2000字"))] pub summary: Option, pub content: Option, + #[validate(length(max = 500, message = "封面URL最多500字"))] pub cover_image: Option, + #[validate(length(max = 100, message = "分类名最多100字"))] pub category: Option, + #[validate(length(max = 100, message = "作者名最多100字"))] pub author: Option, pub published_at: Option>, + #[validate(length(max = 200, message = "slug最多200字"))] pub slug: Option, + #[validate(length(max = 50, message = "内容类型最多50字"))] pub content_type: Option, /// 分类 ID pub category_id: Option, @@ -138,9 +153,10 @@ impl UpdateArticleReq { } /// 审核文章请求 -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct ReviewArticleReq { /// 审核备注 + #[validate(length(max = 2000, message = "审核备注最多2000字"))] pub note: Option, /// 文章版本号(乐观锁) pub version: Option, @@ -184,8 +200,9 @@ pub struct CategoryResp { pub version: i32, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct CreateCategoryReq { + #[validate(length(min = 1, max = 100, message = "分类名长度须在1-100之间"))] pub name: String, pub slug: Option, pub parent_id: Option, @@ -201,8 +218,9 @@ impl CreateCategoryReq { } } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct UpdateCategoryReq { + #[validate(length(min = 1, max = 100, message = "分类名长度须在1-100之间"))] pub name: Option, pub slug: Option, pub parent_id: Option, @@ -234,8 +252,9 @@ pub struct TagResp { pub version: i32, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct CreateTagReq { + #[validate(length(min = 1, max = 50, message = "标签名长度须在1-50之间"))] pub name: String, } @@ -245,8 +264,9 @@ impl CreateTagReq { } } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct UpdateTagReq { + #[validate(length(min = 1, max = 50, message = "标签名长度须在1-50之间"))] pub name: String, pub version: i32, } diff --git a/crates/erp-health/src/handler/article_category_handler.rs b/crates/erp-health/src/handler/article_category_handler.rs index e992b8a..d7333c1 100644 --- a/crates/erp-health/src/handler/article_category_handler.rs +++ b/crates/erp-health/src/handler/article_category_handler.rs @@ -10,6 +10,8 @@ use crate::dto::article_dto::{CategoryResp, CreateCategoryReq, UpdateCategoryReq use crate::service::article_category_service; use crate::state::HealthState; +use validator::Validate; + pub async fn list_categories( State(state): State, Extension(ctx): Extension, @@ -33,6 +35,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.manage")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); let result = article_category_service::create_category(&state, ctx.tenant_id, Some(ctx.user_id), req.0) @@ -51,6 +56,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.manage")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); let result = article_category_service::update_category( &state, diff --git a/crates/erp-health/src/handler/article_handler.rs b/crates/erp-health/src/handler/article_handler.rs index 493ad2f..ae285a1 100644 --- a/crates/erp-health/src/handler/article_handler.rs +++ b/crates/erp-health/src/handler/article_handler.rs @@ -12,6 +12,8 @@ use crate::dto::article_dto::{ use crate::service::article_service; use crate::state::HealthState; +use validator::Validate; + pub async fn list_articles( State(state): State, Extension(ctx): Extension, @@ -107,6 +109,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.manage")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); if req.title.trim().is_empty() { return Err(AppError::Validation("文章标题不能为空".into())); @@ -127,6 +132,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.manage")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); let result = article_service::update_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0) @@ -194,6 +202,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.review")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); let version = req.version.unwrap_or(0); let result = article_service::approve_article( @@ -220,6 +231,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.review")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); let version = req.version.unwrap_or(0); let result = article_service::reject_article( diff --git a/crates/erp-health/src/handler/article_tag_handler.rs b/crates/erp-health/src/handler/article_tag_handler.rs index e9d3c14..55c2dc5 100644 --- a/crates/erp-health/src/handler/article_tag_handler.rs +++ b/crates/erp-health/src/handler/article_tag_handler.rs @@ -10,6 +10,8 @@ use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq}; use crate::service::article_tag_service; use crate::state::HealthState; +use validator::Validate; + pub async fn list_tags( State(state): State, Extension(ctx): Extension, @@ -33,6 +35,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.manage")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); if req.name.trim().is_empty() { return Err(AppError::Validation("标签名称不能为空".into())); @@ -53,6 +58,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.manage")?; + (*req) + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); let result = article_tag_service::update_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0) diff --git a/crates/erp-server/src/handlers/audit_log.rs b/crates/erp-server/src/handlers/audit_log.rs index f507c2c..16abc8b 100644 --- a/crates/erp-server/src/handlers/audit_log.rs +++ b/crates/erp-server/src/handlers/audit_log.rs @@ -3,13 +3,12 @@ use axum::extract::{Extension, FromRef, Query, State}; use axum::response::Json; use axum::routing::get; use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use erp_core::entity::audit_log; use erp_core::error::AppError; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; -/// 审计日志查询参数。 #[derive(Debug, Deserialize)] pub struct AuditLogQuery { pub resource_type: Option, @@ -18,15 +17,82 @@ pub struct AuditLogQuery { pub page_size: Option, } +#[derive(Debug, Serialize)] +pub struct AuditLogResp { + pub id: uuid::Uuid, + pub tenant_id: uuid::Uuid, + pub user_id: Option, + pub user_name: Option, + pub action: String, + pub resource_type: String, + pub resource_id: Option, + pub old_value: Option, + pub new_value: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: chrono::DateTime, +} + +impl From for AuditLogResp { + fn from(m: audit_log::Model) -> Self { + Self { + id: m.id, + tenant_id: m.tenant_id, + user_id: m.user_id, + user_name: None, + action: m.action, + resource_type: m.resource_type, + resource_id: m.resource_id, + old_value: m.old_value, + new_value: m.new_value, + ip_address: m.ip_address, + user_agent: m.user_agent, + created_at: m.created_at, + } + } +} + +async fn resolve_user_names( + db: &sea_orm::DatabaseConnection, + items: &[audit_log::Model], +) -> std::collections::HashMap { + use erp_auth::entity::user; + + let user_ids: Vec = items + .iter() + .filter_map(|i| i.user_id) + .collect::>() + .into_iter() + .collect(); + + if user_ids.is_empty() { + return std::collections::HashMap::new(); + } + + let users = user::Entity::find() + .filter(user::Column::Id.is_in(user_ids)) + .all(db) + .await + .unwrap_or_default(); + + users + .into_iter() + .map(|u| { + let name = u + .display_name + .filter(|n| !n.is_empty()) + .unwrap_or(u.username); + (u.id, name) + }) + .collect() +} + /// GET /audit-logs -/// -/// 分页查询审计日志,支持按 resource_type 和 user_id 过滤。 -/// 租户隔离通过 JWT 中间件注入的 TenantContext 实现。 pub async fn list_audit_logs( State(db): State, Extension(ctx): Extension, Query(params): Query, -) -> Result>>, AppError> +) -> Result>>, AppError> where sea_orm::DatabaseConnection: FromRef, S: Clone + Send + Sync + 'static, @@ -58,10 +124,22 @@ where .await .map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?; + let user_map = resolve_user_names(&db, &items).await; + + let resp_items: Vec = items + .into_iter() + .map(|m| { + let user_name = m.user_id.and_then(|uid| user_map.get(&uid).cloned()); + let mut resp = AuditLogResp::from(m); + resp.user_name = user_name; + resp + }) + .collect(); + let total_pages = total.div_ceil(page_size); Ok(Json(ApiResponse::ok(PaginatedResponse { - data: items, + data: resp_items, total, page, page_size, diff --git a/docs/discussions/2026-05-21-multi-expert-production-readiness-brainstorm.md b/docs/discussions/2026-05-21-multi-expert-production-readiness-brainstorm.md new file mode 100644 index 0000000..35b129d --- /dev/null +++ b/docs/discussions/2026-05-21-multi-expert-production-readiness-brainstorm.md @@ -0,0 +1,176 @@ +# HMS 生产就绪度全面分析 — 多专家组头脑风暴 + +> 日期: 2026-05-21 | 方法论: 5 专家组并行分析 + 浏览器 MCP 实测 + 代码扫描 + Lighthouse 审计 + +## 一、分析方法论 + +| 维度 | 方法 | 数据来源 | +|------|------|----------| +| 架构/代码质量 | 专家组 Agent(429 受限,手动补充) | unwrap 扫描 / validate 扫描 / SQL 注入扫描 | +| 安全 | 专家组 Agent(429 受限,手动补充) | OWASP 检查 / unwrap / localhost / SSRF | +| 医疗合规 | **专家组 Agent 完成** | 17 个子维度逐项评估 | +| 前端 UX/性能 | 专家组 Agent(429 受限,手动补充) | 'any' 扫描 / fetch 扫描 / console.log / Lighthouse | +| DevOps/生产就绪 | **专家组 Agent 完成** | Docker / CI/CD / 监控 / 备份 / 性能 | +| 浏览器实测 | Chrome DevTools MCP | 登录 → 工作台 → 患者管理 → 患者详情 → 告警 → 咨询 → 媒体库 → AI 客服 | + +## 二、各维度评分总览 + +| 维度 | 评分 | 趋势 | 一句话总结 | +|------|------|------|-----------| +| **业务功能完整度** | 8.5/10 A | 稳定 | 5 大业务流程 3 个 COMPLIANT,随访/咨询/告警最完善 | +| **医疗数据合规** | 6.5/10 B | 需提升 | 9 COMPLIANT / 6 PARTIAL / 1 NON-COMPLIANT(药品编码缺失) | +| **前端质量** | 8.0/10 A- | 优秀 | Lighthouse 94/100/100,0 console.log,97.8% loading 覆盖 | +| **安全** | 7.5/10 B+ | 良好 | DTO 校验基本完整,PII 加密到位,~15 handler 缺 validate | +| **DevOps** | 4.0/10 D | **阻塞** | 无 TLS、备份未加密、无监控告警、无法水平扩展 | +| **综合** | **6.9/10 B** | — | 功能强但运维弱,需要 7-10 天集中加固可上线 | + +## 三、CRITICAL 阻塞项(必须修复才能上线) + +### C1. 无 TLS 终端 — DevOps +- **现状**: 无 Nginx/OpenResty 反代配置,HTTP 明文传输 +- **风险**: 医疗数据(PII + 健康记录)明文传输,违反《数据安全法》 +- **修复**: 新增 `docker/nginx/` 含 TLS 配置 + 静态文件缓存 +- **工时**: 1 天 + +### C2. 备份未加密 — DevOps +- **现状**: `pg_dump | gzip` 明文存储 +- **风险**: 备份文件泄露 = 全量患者数据泄露 +- **修复**: `gpg --encrypt` 或 `age` 加密备份文件 +- **工时**: 0.5 天 + +### C3. 无备份恢复验证 — DevOps +- **现状**: 有备份脚本但从未验证恢复 +- **风险**: 备份可能损坏,紧急恢复时才发现 +- **修复**: 新增 `restore.sh` + 月度恢复演练文档 +- **工时**: 1 天 + +### C4. 药品编码完全缺失 — 医疗合规 +- **现状**: `medication_record` 无 ATC/国药准字号,纯文本识别 +- **风险**: 无法做药物相互作用检查、过敏交叉反应分析 +- **修复**: 新增 `medication_code` + `national_drug_code` 字段 + 药品字典表 +- **工时**: 2 天 + +### C5. 无 Prometheus 告警规则 — DevOps +- **现状**: 指标已采集(Prometheus 端点 9090),但无告警规则 +- **风险**: 系统故障无人知道,生产事故发现延迟 +- **修复**: 新增 `docker/prometheus/alerts.yml` + Grafana Dashboard +- **工时**: 1 天 + +## 四、HIGH 优先改进项(影响用户体验和数据完整性) + +### H1. Article Handler 缺少 .validate() 调用 — 安全 +- **现状**: `article_handler.rs` / `article_category_handler.rs` / `article_tag_handler.rs` 共 ~15 个 handler 缺 `.validate()` +- **影响**: 恶意输入可绕过 DTO 校验 +- **工时**: 0.5 天 + +### H2. FHIR Patient 资源字段不完整 — 医疗合规 +- **现状**: 缺 telecom/address/communication/maritalStatus +- **影响**: 与外部 HIS 系统互操作性受限 +- **工时**: 1 天 + +### H3. ICD 编码无格式校验 — 医疗合规 +- **现状**: `icd_code` 是纯字符串,可输入任意值 +- **影响**: 诊断数据无法标准化比较 +- **工时**: 0.5 天 + +### H4. 身份证号/手机号无格式校验 — 医疗合规 +- **现状**: `id_number` 无 18 位校验位验证,`phone` 无 1[3-9]\d{9} 验证 +- **影响**: 脏数据入库,影响患者匹配和去重 +- **工时**: 0.5 天 + +### H5. 前端 'any' 类型残留 — 前端 +- **现状**: 16 处 `any` 跨 5 个文件(client.ts / points.ts / ArticleEditor / MediaLibrary 等) +- **影响**: 类型安全缺口 +- **工时**: 0.5 天 + +### H6. 咨询列表日期选择器英文 — 前端 UX +- **现状**: ConsultationList 日期筛选显示 "Start date" / "End date"(其他页面中文) +- **影响**: 中英文混搭降低专业感 +- **工时**: 0.1 天 + +### H7. 仪表盘审计日志显示 UUID 而非用户名 — 前端 UX +- **现状**: 最近操作记录显示 "019d80" 而非 "系统管理员" +- **影响**: 用户无法理解操作记录 +- **工时**: 0.5 天 + +### H8. uploads 文件无独立备份 — DevOps +- **现状**: Docker volume 无宿主机映射或 S3 同步 +- **影响**: 主机故障丢失所有上传文件(化验单/体检报告) +- **工时**: 0.5 天 + +## 五、MEDIUM 优化项(提升专业度和可维护性) + +### M1. 无分布式追踪(OpenTelemetry)— DevOps +- 工时: 2 天,8 模块跨请求延迟归因 + +### M2. 化验报告 items 缺 LOINC 编码 — 医疗合规 +- 工时: 1 天,检验项标准化 + +### M3. Redis 无持久化配置 — DevOps +- 工时: 0.1 天,添加 `appendonly yes` + +### M4. 级联软删除缺失 — 数据完整性 +- 工时: 1 天,删除患者时级联标记子记录 + +### M5. 预约流程缺签到状态 — 业务 +- 工时: 0.5 天,新增 checked_in 状态 + +### M6. CI 密码硬编码 — DevOps +- 工时: 0.1 天,迁移到 GitHub Secrets + +### M7. npm audit 使用 `|| true` — DevOps +- 工时: 0.1 天,改为白名单机制 + +## 六、头脑风暴:差异化竞争力方向 + +### 方向 A: 医疗数据标准化领先 +> **投入**: 5 天 | **收益**: 与 HIS/LIS 系统互通能力 + +1. 药品字典表(ATC + 国药准字)+ 药物相互作用引擎 +2. ICD-10/ICD-11 编码字典 + 智能编码推荐 +3. LOINC 检验项目字典 + 化验报告标准化 +4. FHIR R4 完整 Patient/Condition/MedicationStatement 映射 + +### 方向 B: 运维自动化领先 +> **投入**: 4 天 | **收益**: 从"不可运维"到"无人值守" + +1. TLS 终端 + 安全头加固 +2. 备份加密 + 恢复验证 + 异地存储 +3. Prometheus 告警 + Grafana Dashboard +4. Redis AOF + DB 连接池可配置 +5. Docker 镜像版本管理 + 自动部署流水线 + +### 方向 C: 用户体验精雕 +> **投入**: 3 天 | **收益**: 专业医疗 SaaS 质感 + +1. 审计日志显示操作者姓名(非 UUID) +2. 全站日期选择器中文化 +3. 咨询分配自动化(当前大量"未分配") +4. 患者合并功能(批量导入去重) +5. 全站 'any' 类型清理 + +## 七、建议实施路径(7-10 天可上线) + +| 阶段 | 天数 | 目标 | 关键交付物 | +|------|------|------|-----------| +| **Phase 0: 阻塞修复** | 2 天 | 消除上线阻断项 | TLS + 备份加密 + 恢复验证 + Prometheus 告警 | +| **Phase 1: 数据加固** | 2 天 | 医疗数据完整性 | validate 补全 + 身份证/手机校验 + ICD 校验 + 药品编码框架 | +| **Phase 2: UX 精雕** | 1.5 天 | 专业 SaaS 质感 | 审计日志姓名 + i18n 统一 + any 清理 + 咨询分配 | +| **Phase 3: 运维完善** | 1.5 天 | 可运维性达标 | Redis AOF + DB 连接池可配 + uploads 备份 + CI 密钥 | +| **Phase 4: 验收上线** | 2 天 | 端到端验收 | 全角色回归测试 + 压力测试 + 灰度上线 | + +## 八、专家组详细报告引用 + +| 专家组 | 状态 | 关键发现 | +|--------|------|----------| +| DevOps | 完整报告 | 4.0/10,5 个 CRITICAL + 5 个 HIGH,P0 约 2.5 天 | +| 医疗合规 | 完整报告 | 9 COMPLIANT / 6 PARTIAL / 1 NON-COMPLIANT(药品编码) | +| 架构/代码 | 手动补充 | unwrap 仅在测试代码,SQL 无注入风险,validate ~15 处缺失 | +| 安全 | 手动补充 | PII 加密到位,SSRF 防护已有,localhost 仅 Ollama 默认值 | +| 前端 UX | 手动补充 | Lighthouse 94/100/100,16 处 any,213 处 catch,0 console.log | + +## 九、结论 + +HMS 系统在**业务功能层面已达到生产级别**(随访/咨询/告警三大流程 COMPLIANT,前端 Lighthouse 94 分),但**运维基础设施是上线最大瓶颈**(4.0/10)。核心差距不在代码质量,而在运维自动化和医疗数据标准化。 + +**推荐路径**: 先花 2 天消除 DevOps 阻塞项(TLS + 备份 + 监控),再用 3 天补齐数据校验和 UX 精雕,最后 2 天全角色验收。总计 7 天可达到用户可测试版本标准。