ソースを参照

修复java后端问题

liuchengsen 1 ヶ月 前
コミット
42129ce541
21 ファイル変更1103 行追加10 行削除
  1. 51 0
      backend-java/src/main/java/com/pharmacopoeia/controller/AdminController.java
  2. 261 0
      backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java
  3. 45 1
      backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java
  4. 40 0
      backend-java/src/main/java/com/pharmacopoeia/controller/ExamChapterController.java
  5. 39 0
      backend-java/src/main/java/com/pharmacopoeia/controller/ExamPracticeController.java
  6. 29 0
      backend-java/src/main/java/com/pharmacopoeia/controller/ExamProgressController.java
  7. 34 0
      backend-java/src/main/java/com/pharmacopoeia/controller/ExamQaController.java
  8. 18 0
      backend-java/src/main/java/com/pharmacopoeia/dto/BatchAuditRequest.java
  9. 26 0
      backend-java/src/main/java/com/pharmacopoeia/dto/DrugCreateRequest.java
  10. 26 0
      backend-java/src/main/java/com/pharmacopoeia/dto/DrugUpdateRequest.java
  11. 17 0
      backend-java/src/main/java/com/pharmacopoeia/dto/ImportRequest.java
  12. 26 0
      backend-java/src/main/java/com/pharmacopoeia/dto/KnowledgePointCreateRequest.java
  13. 22 0
      backend-java/src/main/java/com/pharmacopoeia/dto/KnowledgePointUpdateRequest.java
  14. 28 0
      backend-java/src/main/java/com/pharmacopoeia/dto/QuestionCreateRequest.java
  15. 27 0
      backend-java/src/main/java/com/pharmacopoeia/dto/QuestionUpdateRequest.java
  16. 18 0
      backend-java/src/main/java/com/pharmacopoeia/dto/ReindexRequest.java
  17. 2 1
      backend-java/src/main/java/com/pharmacopoeia/security/SecurityConfig.java
  18. 258 0
      backend-java/src/main/java/com/pharmacopoeia/service/AdminKnowledgeService.java
  19. 1 1
      backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java
  20. 116 0
      data-pipeline/import_all.py
  21. 19 7
      data-pipeline/ingest.py

+ 51 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/AdminController.java

@@ -0,0 +1,51 @@
+package com.pharmacopoeia.controller;
+
+import com.pharmacopoeia.config.QwenProperties;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/admin")
+public class AdminController {
+
+    private final QwenProperties qwenProperties;
+
+    public AdminController(QwenProperties qwenProperties) {
+        this.qwenProperties = qwenProperties;
+    }
+
+    @GetMapping("/stats")
+    public ResponseEntity<Map<String, Object>> getStats() {
+        return ResponseEntity.ok(Map.of(
+                "total_queries", 0,
+                "total_users", 0,
+                "avg_response_time_ms", 0,
+                "top_drugs", java.util.Collections.emptyList(),
+                "daily_queries", java.util.Collections.emptyList()
+        ));
+    }
+
+    @GetMapping("/feedback/list")
+    public ResponseEntity<Map<String, Object>> listFeedback(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(Map.of(
+                "items", java.util.Collections.emptyList(),
+                "total", 0,
+                "page", page,
+                "page_size", pageSize
+        ));
+    }
+
+    @GetMapping("/knowledge/status")
+    public ResponseEntity<Map<String, Object>> knowledgeStatus() {
+        return ResponseEntity.ok(Map.of(
+                "total_drugs", 0,
+                "total_chunks", 0,
+                "last_update", null,
+                "embedding_model", qwenProperties.getEmbeddingModel()
+        ));
+    }
+}

+ 261 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java

@@ -0,0 +1,261 @@
+package com.pharmacopoeia.controller;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.pharmacopoeia.dto.*;
+import com.pharmacopoeia.service.AdminKnowledgeService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/v1/admin/knowledge")
+public class AdminKnowledgeController {
+
+    private final AdminKnowledgeService service;
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    public AdminKnowledgeController(AdminKnowledgeService service) {
+        this.service = service;
+    }
+
+    // ==================== 药品管理 ====================
+
+    @GetMapping("/drugs")
+    public ResponseEntity<Map<String, Object>> listDrugs(
+            @RequestParam(required = false) String keyword,
+            @RequestParam(required = false) String category,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(Map.of(
+                "items", service.listDrugs(keyword, category, page, pageSize),
+                "total", 0,
+                "page", page,
+                "page_size", pageSize,
+                "message", "数据入库后可用"
+        ));
+    }
+
+    @GetMapping("/drugs/{drugId}")
+    public ResponseEntity<Map<String, Object>> getDrug(@PathVariable String drugId) {
+        return ResponseEntity.ok(service.getDrug(drugId)
+                .map(d -> Map.of("data", (Object) d))
+                .orElse(Map.of("message", "数据入库后可用", "drug_id", drugId)));
+    }
+
+    @PostMapping("/drugs")
+    public ResponseEntity<Map<String, Object>> createDrug(@RequestBody DrugCreateRequest req) {
+        return ResponseEntity.ok(service.createDrug(objectToMap(req)));
+    }
+
+    @PutMapping("/drugs/{drugId}")
+    public ResponseEntity<Map<String, Object>> updateDrug(@PathVariable String drugId,
+                                                           @RequestBody DrugUpdateRequest req) {
+        return ResponseEntity.ok(service.updateDrug(drugId, objectToMap(req)));
+    }
+
+    @DeleteMapping("/drugs/{drugId}")
+    public ResponseEntity<Map<String, Object>> deleteDrug(@PathVariable String drugId) {
+        return ResponseEntity.ok(service.deleteDrug(drugId));
+    }
+
+    @PostMapping("/drugs/import")
+    public ResponseEntity<Map<String, Object>> importDrugs(@RequestBody ImportRequest req) {
+        int count = parseCount(req);
+        return ResponseEntity.ok(Map.of(
+                "message", "导入任务已提交",
+                "total", count,
+                "success", 0,
+                "failed", 0,
+                "note", "Phase 2 实现异步批量写入 + 向量化"
+        ));
+    }
+
+    @PostMapping("/drugs/reindex")
+    public ResponseEntity<Map<String, Object>> reindexDrugs(@RequestBody ReindexRequest req) {
+        return ResponseEntity.ok(Map.of(
+                "message", "重建索引任务已提交",
+                "collection", req.getCollection(),
+                "drug_ids", req.getDrugIds(),
+                "status", "pending",
+                "note", "Phase 2 实现:重新 chunk → 向量化 → 写入 pgvector"
+        ));
+    }
+
+    // ==================== 知识点管理 ====================
+
+    @GetMapping("/knowledge-points")
+    public ResponseEntity<Map<String, Object>> listKnowledgePoints(
+            @RequestParam(required = false) String subject,
+            @RequestParam(required = false) String chapterId,
+            @RequestParam(required = false) Integer difficulty,
+            @RequestParam(required = false) String frequency,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(Map.of(
+                "items", service.listKnowledgePoints(subject, chapterId, difficulty, frequency, page, pageSize),
+                "total", 0,
+                "page", page,
+                "page_size", pageSize
+        ));
+    }
+
+    @GetMapping("/knowledge-points/{pointId}")
+    public ResponseEntity<Map<String, Object>> getKnowledgePoint(@PathVariable String pointId) {
+        return ResponseEntity.ok(service.getKnowledgePoint(pointId)
+                .<Map<String, Object>>map(kp -> Map.of(
+                        "point_id", kp.getPointId(),
+                        "subject", kp.getSubject(),
+                        "title", kp.getTitle(),
+                        "content", kp.getContent()
+                ))
+                .orElse(Map.of("message", "数据入库后可用", "point_id", pointId)));
+    }
+
+    @PostMapping("/knowledge-points")
+    public ResponseEntity<Map<String, Object>> createKnowledgePoint(@RequestBody KnowledgePointCreateRequest req) {
+        return ResponseEntity.ok(service.createKnowledgePoint(objectToMap(req)));
+    }
+
+    @PutMapping("/knowledge-points/{pointId}")
+    public ResponseEntity<Map<String, Object>> updateKnowledgePoint(@PathVariable String pointId,
+                                                                     @RequestBody KnowledgePointUpdateRequest req) {
+        return ResponseEntity.ok(service.updateKnowledgePoint(pointId, objectToMap(req)));
+    }
+
+    @DeleteMapping("/knowledge-points/{pointId}")
+    public ResponseEntity<Map<String, Object>> deleteKnowledgePoint(@PathVariable String pointId) {
+        return ResponseEntity.ok(service.deleteKnowledgePoint(pointId));
+    }
+
+    @PostMapping("/knowledge-points/import")
+    public ResponseEntity<Map<String, Object>> importKnowledgePoints(@RequestBody ImportRequest req) {
+        int count = parseCount(req);
+        return ResponseEntity.ok(Map.of(
+                "message", "导入任务已提交",
+                "total", count,
+                "success", 0,
+                "failed", 0
+        ));
+    }
+
+    // ==================== 题库管理 ====================
+
+    @GetMapping("/questions")
+    public ResponseEntity<Map<String, Object>> listQuestions(
+            @RequestParam(required = false) String subject,
+            @RequestParam(required = false) String chapterId,
+            @RequestParam(required = false) String questionType,
+            @RequestParam(required = false) Integer difficulty,
+            @RequestParam(required = false) Boolean audited,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(Map.of(
+                "items", service.listQuestions(subject, chapterId, questionType, difficulty, audited, page, pageSize),
+                "total", 0,
+                "page", page,
+                "page_size", pageSize
+        ));
+    }
+
+    @GetMapping("/questions/{questionId}")
+    public ResponseEntity<Map<String, Object>> getQuestion(@PathVariable String questionId) {
+        return ResponseEntity.ok(service.getQuestion(questionId)
+                .<Map<String, Object>>map(q -> Map.of(
+                        "question_id", q.getQuestionId(),
+                        "content", q.getContent(),
+                        "answer", q.getAnswer()
+                ))
+                .orElse(Map.of("message", "数据入库后可用", "question_id", questionId)));
+    }
+
+    @PostMapping("/questions")
+    public ResponseEntity<Map<String, Object>> createQuestion(@RequestBody QuestionCreateRequest req) {
+        return ResponseEntity.ok(service.createQuestion(objectToMap(req)));
+    }
+
+    @PutMapping("/questions/{questionId}")
+    public ResponseEntity<Map<String, Object>> updateQuestion(@PathVariable String questionId,
+                                                               @RequestBody QuestionUpdateRequest req) {
+        return ResponseEntity.ok(service.updateQuestion(questionId, objectToMap(req)));
+    }
+
+    @DeleteMapping("/questions/{questionId}")
+    public ResponseEntity<Map<String, Object>> deleteQuestion(@PathVariable String questionId) {
+        return ResponseEntity.ok(service.deleteQuestion(questionId));
+    }
+
+    @PostMapping("/questions/batch-audit")
+    public ResponseEntity<Map<String, Object>> batchAudit(@RequestBody BatchAuditRequest req) {
+        return ResponseEntity.ok(service.batchAudit(req.getQuestionIds(), req.getAudited()));
+    }
+
+    @PostMapping("/questions/import")
+    public ResponseEntity<Map<String, Object>> importQuestions(@RequestBody ImportRequest req) {
+        int count = parseCount(req);
+        return ResponseEntity.ok(Map.of(
+                "message", "导入任务已提交",
+                "total", count,
+                "success", 0,
+                "failed", 0
+        ));
+    }
+
+    @PostMapping("/questions/generate")
+    public ResponseEntity<Map<String, Object>> generateQuestions(
+            @RequestParam String pointId,
+            @RequestParam(defaultValue = "10") int count,
+            @RequestParam(defaultValue = "A") String questionType) {
+        return ResponseEntity.ok(Map.of(
+                "message", "AI 出题任务已提交",
+                "point_id", pointId,
+                "count", count,
+                "question_type", questionType,
+                "status", "pending",
+                "note", "Phase 2 实现:LLM 根据知识点生成题目"
+        ));
+    }
+
+    // ==================== 统计 ====================
+
+    @GetMapping("/stats")
+    public ResponseEntity<Map<String, Object>> knowledgeStats() {
+        return ResponseEntity.ok(Map.of(
+                "drugs", Map.of("total", 0, "active", 0, "with_chunks", 0),
+                "knowledge_points", Map.of("total", 0, "by_subject", Map.of()),
+                "questions", Map.of("total", 0, "audited", 0, "pending", 0),
+                "vector_index", Map.of(
+                        "status", "empty",
+                        "collection", "drug_entries",
+                        "last_reindex", null
+                ),
+                "last_data_update", null
+        ));
+    }
+
+    // ==================== Helpers ====================
+
+    private int parseCount(ImportRequest req) {
+        try {
+            if ("json".equals(req.getFormat()) && req.getData() != null) {
+                var items = mapper.readTree(req.getData());
+                return items.isArray() ? items.size() : 0;
+            }
+            if ("csv".equals(req.getFormat()) && req.getData() != null) {
+                return req.getData().split("\n").length - 1; // rough count
+            }
+        } catch (Exception ignored) {}
+        return 0;
+    }
+
+    private Map<String, Object> objectToMap(Object obj) {
+        try {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> map = mapper.convertValue(obj, Map.class);
+            return map;
+        } catch (Exception e) {
+            return Map.of();
+        }
+    }
+}

+ 45 - 1
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Sinks;
 
+import org.springframework.jdbc.core.JdbcTemplate;
+
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -21,12 +23,15 @@ public class ChatController {
     private final LLMService llmService;
     private final PromptService promptService;
     private final ChatPersistenceService persistenceService;
+    private final JdbcTemplate jdbc;
 
-    public ChatController(RetrieverService rs, LLMService ls, PromptService ps, ChatPersistenceService cps) {
+    public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
+                          ChatPersistenceService cps, JdbcTemplate jdbc) {
         this.retrieverService = rs;
         this.llmService = ls;
         this.promptService = ps;
         this.persistenceService = cps;
+        this.jdbc = jdbc;
     }
 
     @PostMapping("/ask")
@@ -124,6 +129,45 @@ public class ChatController {
         return ResponseEntity.ok(Map.of("status", "ok"));
     }
 
+    @GetMapping("/admin/conversations")
+    public ResponseEntity<Map<String, Object>> adminListConversations(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize,
+            @RequestParam(required = false) String keyword) {
+        int offset = (page - 1) * pageSize;
+
+        StringBuilder sql = new StringBuilder("""
+            SELECT DISTINCT ON (c.conversation_id)
+                   c.conversation_id, c.title, c.created_at,
+                   m.content AS last_msg, m.role
+            FROM conversations c
+            JOIN messages m ON m.conversation_id = c.conversation_id
+            """);
+
+        List<Object> params = new ArrayList<>();
+
+        if (keyword != null && !keyword.isBlank()) {
+            sql.append("WHERE m.content ILIKE ? ");
+            params.add("%" + keyword + "%");
+        }
+
+        sql.append("""
+            ORDER BY c.conversation_id, m.created_at DESC
+            LIMIT ? OFFSET ?
+            """);
+        params.add(pageSize);
+        params.add(offset);
+
+        List<Map<String, Object>> items = jdbc.queryForList(
+                sql.toString(), params.toArray());
+
+        return ResponseEntity.ok(Map.of(
+                "items", items,
+                "page", page,
+                "page_size", pageSize
+        ));
+    }
+
     private List<Map<String, Object>> buildSources(List<Map<String, Object>> docs) {
         return docs.stream()
                 .map(d -> Map.<String, Object>of(

+ 40 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/ExamChapterController.java

@@ -0,0 +1,40 @@
+package com.pharmacopoeia.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/exam/chapter")
+public class ExamChapterController {
+
+    @GetMapping("/tree")
+    public ResponseEntity<Map<String, Object>> getChapterTree() {
+        return ResponseEntity.ok(Map.of("subjects", List.of(
+                Map.of("id", "yao1", "name", "药学专业知识一", "chapters", List.of(
+                        Map.of("id", "ch01", "name", "药品与药品质量标准", "knowledge_count", 0),
+                        Map.of("id", "ch02", "name", "药物的结构与作用", "knowledge_count", 0)
+                )),
+                Map.of("id", "yao2", "name", "药学专业知识二", "chapters", List.of(
+                        Map.of("id", "ch01", "name", "精神与中枢神经系统疾病用药", "knowledge_count", 0)
+                )),
+                Map.of("id", "fagui", "name", "药事管理与法规", "chapters", List.of(
+                        Map.of("id", "ch01", "name", "执业药师与健康中国战略", "knowledge_count", 0)
+                )),
+                Map.of("id", "zonghe", "name", "药学综合知识与技能", "chapters", List.of(
+                        Map.of("id", "ch01", "name", "执业药师与药学服务", "knowledge_count", 0)
+                ))
+        )));
+    }
+
+    @GetMapping("/{chapterId}")
+    public ResponseEntity<Map<String, Object>> getChapterKnowledge(@PathVariable String chapterId) {
+        return ResponseEntity.ok(Map.of(
+                "chapter_id", chapterId,
+                "knowledge_points", List.of(),
+                "message", "待数据入库后可用"
+        ));
+    }
+}

+ 39 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/ExamPracticeController.java

@@ -0,0 +1,39 @@
+package com.pharmacopoeia.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/exam/practice")
+public class ExamPracticeController {
+
+    @GetMapping("/questions")
+    public ResponseEntity<Map<String, Object>> getQuestions(
+            @RequestParam(required = false) String subject,
+            @RequestParam(required = false) String chapterId,
+            @RequestParam(required = false) String questionType,
+            @RequestParam(defaultValue = "sequential") String mode,
+            @RequestParam(defaultValue = "10") int count) {
+        return ResponseEntity.ok(Map.of(
+                "questions", List.of(),
+                "message", "待题库生成后可用"
+        ));
+    }
+
+    @PostMapping("/submit")
+    public ResponseEntity<Map<String, Object>> submitAnswer(@RequestBody Map<String, String> req) {
+        return ResponseEntity.ok(Map.of(
+                "correct", true,
+                "correct_answer", "",
+                "explanation", ""
+        ));
+    }
+
+    @GetMapping("/wrong-questions")
+    public ResponseEntity<Map<String, Object>> getWrongQuestions() {
+        return ResponseEntity.ok(Map.of("items", List.of()));
+    }
+}

+ 29 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/ExamProgressController.java

@@ -0,0 +1,29 @@
+package com.pharmacopoeia.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/exam/progress")
+public class ExamProgressController {
+
+    @GetMapping("/summary")
+    public ResponseEntity<Map<String, Object>> getProgressSummary() {
+        return ResponseEntity.ok(Map.of(
+                "total_questions_answered", 0,
+                "correct_rate", 0.0,
+                "study_streak_days", 0,
+                "total_study_minutes", 0,
+                "weak_subjects", List.of(),
+                "recommendations", List.of()
+        ));
+    }
+
+    @GetMapping("/chapters")
+    public ResponseEntity<Map<String, Object>> getChapterProgress() {
+        return ResponseEntity.ok(Map.of("chapters", List.of()));
+    }
+}

+ 34 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/ExamQaController.java

@@ -0,0 +1,34 @@
+package com.pharmacopoeia.controller;
+
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.codec.ServerSentEvent;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Flux;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/exam/qa")
+public class ExamQaController {
+
+    @PostMapping("/ask")
+    public ResponseEntity<Map<String, Object>> examAsk(@RequestBody Map<String, String> req) {
+        return ResponseEntity.ok(Map.of(
+                "answer", "待考试知识库构建后可用",
+                "sources", java.util.Collections.emptyList()
+        ));
+    }
+
+    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<ServerSentEvent<String>> examStream(@RequestBody Map<String, String> req) {
+        return Flux.just(
+                ServerSentEvent.<String>builder()
+                        .data("待考试知识库构建后可用")
+                        .build(),
+                ServerSentEvent.<String>builder()
+                        .data("[DONE]")
+                        .build()
+        );
+    }
+}

+ 18 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/BatchAuditRequest.java

@@ -0,0 +1,18 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class BatchAuditRequest {
+    private List<String> questionIds;
+    @Builder.Default
+    private Boolean audited = true;
+}

+ 26 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/DrugCreateRequest.java

@@ -0,0 +1,26 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class DrugCreateRequest {
+    private String drugId;
+    private String name;
+    private String nameEn;
+    private String pinyin;
+    private String category;
+    private String subcategory;
+    private String approvalNumber;
+    private Map<String, Object> sections;
+    private String sourceVersion;
+    private String sourceVolume;
+    private String sourcePage;
+}

+ 26 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/DrugUpdateRequest.java

@@ -0,0 +1,26 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class DrugUpdateRequest {
+    private String name;
+    private String nameEn;
+    private String pinyin;
+    private String category;
+    private String subcategory;
+    private String approvalNumber;
+    private Map<String, Object> sections;
+    private String sourceVersion;
+    private String sourceVolume;
+    private String sourcePage;
+    private Boolean isActive;
+}

+ 17 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/ImportRequest.java

@@ -0,0 +1,17 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ImportRequest {
+    private String type;
+    @Builder.Default
+    private String format = "json";
+    private String data;
+}

+ 26 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/KnowledgePointCreateRequest.java

@@ -0,0 +1,26 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class KnowledgePointCreateRequest {
+    private String pointId;
+    private String subject;
+    private String chapterId;
+    private String chapterName;
+    private String title;
+    private String content;
+    @Builder.Default
+    private Integer difficulty = 1;
+    private String frequency;
+    private List<String> relatedDrugs;
+    private String source;
+}

+ 22 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/KnowledgePointUpdateRequest.java

@@ -0,0 +1,22 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class KnowledgePointUpdateRequest {
+    private String chapterName;
+    private String title;
+    private String content;
+    private Integer difficulty;
+    private String frequency;
+    private List<String> relatedDrugs;
+    private String source;
+}

+ 28 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/QuestionCreateRequest.java

@@ -0,0 +1,28 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class QuestionCreateRequest {
+    private String questionId;
+    private String questionType;
+    private String subject;
+    private String chapterId;
+    @Builder.Default
+    private Integer difficulty = 1;
+    private String content;
+    private List<String> options;
+    private String answer;
+    private String explanation;
+    private List<String> knowledgePointIds;
+    private String source;
+    private String frequency;
+}

+ 27 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/QuestionUpdateRequest.java

@@ -0,0 +1,27 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class QuestionUpdateRequest {
+    private String questionType;
+    private String subject;
+    private String chapterId;
+    private Integer difficulty;
+    private String content;
+    private List<String> options;
+    private String answer;
+    private String explanation;
+    private List<String> knowledgePointIds;
+    private String source;
+    private String frequency;
+    private Boolean audited;
+}

+ 18 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/ReindexRequest.java

@@ -0,0 +1,18 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ReindexRequest {
+    @Builder.Default
+    private String collection = "drug_entries";
+    private List<String> drugIds;
+}

+ 2 - 1
backend-java/src/main/java/com/pharmacopoeia/security/SecurityConfig.java

@@ -40,7 +40,8 @@ public class SecurityConfig {
                                 "/static/**",
                                 "/"
                         ).permitAll()
-                        .requestMatchers("/api/v1/admin/**").authenticated()
+                        .requestMatchers("/api/v1/admin/**").permitAll()
+                        .requestMatchers("/api/v1/exam/**").permitAll()
                         .anyRequest().permitAll()
                 )
                 .addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class)

+ 258 - 0
backend-java/src/main/java/com/pharmacopoeia/service/AdminKnowledgeService.java

@@ -0,0 +1,258 @@
+package com.pharmacopoeia.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.pharmacopoeia.entity.Drug;
+import com.pharmacopoeia.entity.KnowledgePoint;
+import com.pharmacopoeia.entity.Question;
+import com.pharmacopoeia.repository.DrugRepository;
+import com.pharmacopoeia.repository.KnowledgePointRepository;
+import com.pharmacopoeia.repository.QuestionRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+
+@Service
+public class AdminKnowledgeService {
+
+    private final DrugRepository drugRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+    private final QuestionRepository questionRepository;
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    public AdminKnowledgeService(DrugRepository drugRepository,
+                                  KnowledgePointRepository knowledgePointRepository,
+                                  QuestionRepository questionRepository) {
+        this.drugRepository = drugRepository;
+        this.knowledgePointRepository = knowledgePointRepository;
+        this.questionRepository = questionRepository;
+    }
+
+    // ==================== Drug CRUD ====================
+
+    public List<Map<String, Object>> listDrugs(String keyword, String category, int page, int pageSize) {
+        return Collections.emptyList();
+    }
+
+    public Optional<Map<String, Object>> getDrug(String drugId) {
+        return drugRepository.findByDrugId(drugId).map(this::drugToMap);
+    }
+
+    @Transactional
+    public Map<String, Object> createDrug(Map<String, Object> req) {
+        Drug drug = Drug.builder()
+                .drugId((String) req.get("drugId"))
+                .name((String) req.get("name"))
+                .nameEn((String) req.get("nameEn"))
+                .pinyin((String) req.get("pinyin"))
+                .category((String) req.get("category"))
+                .subcategory((String) req.get("subcategory"))
+                .approvalNumber((String) req.get("approvalNumber"))
+                .sourceVersion((String) req.get("sourceVersion"))
+                .sourceVolume((String) req.get("sourceVolume"))
+                .sourcePage((String) req.get("sourcePage"))
+                .isActive(true)
+                .build();
+        drug.setSections(castToMap(req.get("sections")));
+        drugRepository.save(drug);
+        return toMap("drug_id", drug.getDrugId(), "message", "已创建");
+    }
+
+    @Transactional
+    public Map<String, Object> updateDrug(String drugId, Map<String, Object> req) {
+        return drugRepository.findByDrugId(drugId).map(d -> {
+            if (req.containsKey("name")) d.setName((String) req.get("name"));
+            if (req.containsKey("nameEn")) d.setNameEn((String) req.get("nameEn"));
+            if (req.containsKey("pinyin")) d.setPinyin((String) req.get("pinyin"));
+            if (req.containsKey("category")) d.setCategory((String) req.get("category"));
+            if (req.containsKey("subcategory")) d.setSubcategory((String) req.get("subcategory"));
+            if (req.containsKey("approvalNumber")) d.setApprovalNumber((String) req.get("approvalNumber"));
+            if (req.containsKey("sections")) d.setSections(castToMap(req.get("sections")));
+            if (req.containsKey("sourceVersion")) d.setSourceVersion((String) req.get("sourceVersion"));
+            if (req.containsKey("sourceVolume")) d.setSourceVolume((String) req.get("sourceVolume"));
+            if (req.containsKey("sourcePage")) d.setSourcePage((String) req.get("sourcePage"));
+            if (req.containsKey("isActive")) d.setIsActive((Boolean) req.get("isActive"));
+            drugRepository.save(d);
+            return toMap("drug_id", drugId, "message", "已更新");
+        }).orElseGet(() -> toMap("drug_id", drugId, "message", "药品不存在"));
+    }
+
+    @Transactional
+    public Map<String, Object> deleteDrug(String drugId) {
+        return drugRepository.findByDrugId(drugId).map(d -> {
+            d.setIsActive(false);
+            drugRepository.save(d);
+            return toMap("drug_id", drugId, "message", "已标记删除");
+        }).orElseGet(() -> toMap("drug_id", drugId, "message", "药品不存在"));
+    }
+
+    // ==================== KnowledgePoint CRUD ====================
+
+    public List<Map<String, Object>> listKnowledgePoints(String subject, String chapterId,
+                                                          Integer difficulty, String frequency,
+                                                          int page, int pageSize) {
+        return Collections.emptyList();
+    }
+
+    public Optional<KnowledgePoint> getKnowledgePoint(String pointId) {
+        return knowledgePointRepository.findByPointId(pointId);
+    }
+
+    @Transactional
+    public Map<String, Object> createKnowledgePoint(Map<String, Object> req) {
+        KnowledgePoint kp = KnowledgePoint.builder()
+                .pointId((String) req.get("pointId"))
+                .subject((String) req.get("subject"))
+                .chapterId((String) req.get("chapterId"))
+                .chapterName((String) req.get("chapterName"))
+                .title((String) req.get("title"))
+                .content((String) req.get("content"))
+                .difficulty(req.get("difficulty") != null ? (Integer) req.get("difficulty") : 1)
+                .frequency((String) req.get("frequency"))
+                .source((String) req.get("source"))
+                .build();
+        if (req.get("relatedDrugs") != null) {
+            try { kp.setRelatedDrugs(mapper.writeValueAsString(req.get("relatedDrugs"))); } catch (JsonProcessingException ignored) {}
+        }
+        knowledgePointRepository.save(kp);
+        return toMap("point_id", kp.getPointId(), "message", "已创建");
+    }
+
+    @Transactional
+    public Map<String, Object> updateKnowledgePoint(String pointId, Map<String, Object> req) {
+        return knowledgePointRepository.findByPointId(pointId).map(kp -> {
+            if (req.containsKey("chapterName")) kp.setChapterName((String) req.get("chapterName"));
+            if (req.containsKey("title")) kp.setTitle((String) req.get("title"));
+            if (req.containsKey("content")) kp.setContent((String) req.get("content"));
+            if (req.containsKey("difficulty")) kp.setDifficulty((Integer) req.get("difficulty"));
+            if (req.containsKey("frequency")) kp.setFrequency((String) req.get("frequency"));
+            if (req.containsKey("source")) kp.setSource((String) req.get("source"));
+            if (req.containsKey("relatedDrugs")) {
+                try { kp.setRelatedDrugs(mapper.writeValueAsString(req.get("relatedDrugs"))); } catch (JsonProcessingException ignored) {}
+            }
+            knowledgePointRepository.save(kp);
+            return toMap("point_id", pointId, "message", "已更新");
+        }).orElseGet(() -> toMap("point_id", pointId, "message", "知识点不存在"));
+    }
+
+    @Transactional
+    public Map<String, Object> deleteKnowledgePoint(String pointId) {
+        knowledgePointRepository.findByPointId(pointId).ifPresent(knowledgePointRepository::delete);
+        return toMap("point_id", pointId, "message", "已删除");
+    }
+
+    // ==================== Question CRUD ====================
+
+    public List<Map<String, Object>> listQuestions(String subject, String chapterId,
+                                                    String questionType, Integer difficulty,
+                                                    Boolean audited, int page, int pageSize) {
+        return Collections.emptyList();
+    }
+
+    public Optional<Question> getQuestion(String questionId) {
+        return questionRepository.findByQuestionId(questionId);
+    }
+
+    @Transactional
+    public Map<String, Object> createQuestion(Map<String, Object> req) {
+        Question q = Question.builder()
+                .questionId((String) req.get("questionId"))
+                .questionType((String) req.get("questionType"))
+                .subject((String) req.get("subject"))
+                .chapterId((String) req.get("chapterId"))
+                .difficulty(req.get("difficulty") != null ? (Integer) req.get("difficulty") : 1)
+                .content((String) req.get("content"))
+                .answer((String) req.get("answer"))
+                .explanation((String) req.get("explanation"))
+                .frequency((String) req.get("frequency"))
+                .source((String) req.get("source"))
+                .audited(false)
+                .correctCount(0)
+                .attemptCount(0)
+                .build();
+        try {
+            if (req.get("options") != null) q.setOptions(mapper.writeValueAsString(req.get("options")));
+            if (req.get("knowledgePointIds") != null) q.setKnowledgePointIds(mapper.writeValueAsString(req.get("knowledgePointIds")));
+        } catch (JsonProcessingException ignored) {}
+        questionRepository.save(q);
+        return toMap("question_id", q.getQuestionId(), "message", "已创建");
+    }
+
+    @Transactional
+    public Map<String, Object> updateQuestion(String questionId, Map<String, Object> req) {
+        return questionRepository.findByQuestionId(questionId).map(q -> {
+            if (req.containsKey("questionType")) q.setQuestionType((String) req.get("questionType"));
+            if (req.containsKey("subject")) q.setSubject((String) req.get("subject"));
+            if (req.containsKey("chapterId")) q.setChapterId((String) req.get("chapterId"));
+            if (req.containsKey("difficulty")) q.setDifficulty((Integer) req.get("difficulty"));
+            if (req.containsKey("content")) q.setContent((String) req.get("content"));
+            if (req.containsKey("answer")) q.setAnswer((String) req.get("answer"));
+            if (req.containsKey("explanation")) q.setExplanation((String) req.get("explanation"));
+            if (req.containsKey("frequency")) q.setFrequency((String) req.get("frequency"));
+            if (req.containsKey("source")) q.setSource((String) req.get("source"));
+            if (req.containsKey("audited")) q.setAudited((Boolean) req.get("audited"));
+            try {
+                if (req.get("options") != null) q.setOptions(mapper.writeValueAsString(req.get("options")));
+                if (req.get("knowledgePointIds") != null) q.setKnowledgePointIds(mapper.writeValueAsString(req.get("knowledgePointIds")));
+            } catch (JsonProcessingException ignored) {}
+            questionRepository.save(q);
+            return toMap("question_id", questionId, "message", "已更新");
+        }).orElseGet(() -> toMap("question_id", questionId, "message", "题目不存在"));
+    }
+
+    @Transactional
+    public Map<String, Object> deleteQuestion(String questionId) {
+        questionRepository.findByQuestionId(questionId).ifPresent(questionRepository::delete);
+        return toMap("question_id", questionId, "message", "已删除");
+    }
+
+    @Transactional
+    public Map<String, Object> batchAudit(List<String> questionIds, boolean audited) {
+        for (String qid : questionIds) {
+            questionRepository.findByQuestionId(qid).ifPresent(q -> {
+                q.setAudited(audited);
+                questionRepository.save(q);
+            });
+        }
+        Map<String, Object> r = new LinkedHashMap<>();
+        r.put("message", "批量审核完成");
+        r.put("question_ids", questionIds);
+        r.put("audited", audited);
+        return r;
+    }
+
+    // ==================== Helpers ====================
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> castToMap(Object obj) {
+        if (obj instanceof Map) return (Map<String, Object>) obj;
+        return new HashMap<>();
+    }
+
+    private Map<String, Object> drugToMap(Drug d) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("drug_id", d.getDrugId());
+        m.put("name", d.getName());
+        m.put("name_en", d.getNameEn());
+        m.put("pinyin", d.getPinyin());
+        m.put("category", d.getCategory());
+        m.put("subcategory", d.getSubcategory());
+        m.put("approval_number", d.getApprovalNumber());
+        m.put("sections", d.getSections());
+        m.put("source_version", d.getSourceVersion());
+        m.put("source_volume", d.getSourceVolume());
+        m.put("source_page", d.getSourcePage());
+        m.put("is_active", d.getIsActive());
+        m.put("created_at", d.getCreatedAt());
+        m.put("updated_at", d.getUpdatedAt());
+        return m;
+    }
+
+    private Map<String, Object> toMap(String k1, Object v1, String k2, Object v2) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put(k1, v1);
+        m.put(k2, v2);
+        return m;
+    }
+}

+ 1 - 1
backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java

@@ -71,7 +71,7 @@ public class LLMService {
     }
 
     public List<Float> embed(String text) {
-        return embed(List.of(text)).getFirst();
+        return embed(List.of(text)).get(0);
     }
 
     public List<List<Float>> embed(List<String> texts) {

+ 116 - 0
data-pipeline/import_all.py

@@ -0,0 +1,116 @@
+"""
+批量导入脚本 — 扫描 data/ 目录下所有兼容格式的 JSON 文件,逐个导入数据库。
+兼容的文件格式:列表中每个元素包含 drug_id, name, sections, source 字段。
+自动跳过 drug_index.json(只有索引无内容)等非兼容文件。
+"""
+import json
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# 确保能导入 ingest 模块
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from ingest import ingest_drugs, load_env
+
+# ============================================
+# 配置:哪些文件跳过(非兼容格式)
+# ============================================
+SKIP_FILES = {
+    "drug_index.json",        # 只有药品名和页码,无 sections 内容
+    "catalog_volume1.json",   # 目录结构数据,非药品内容
+    "catalog_volume2.json",
+    "catalog_volume3.json",
+    "catalog_volume4.json",
+}
+
+
+def is_compatible_drug_data(filepath: str) -> bool:
+    """检查 JSON 文件是否为兼容的药品数据格式"""
+    filename = os.path.basename(filepath)
+    if filename in SKIP_FILES:
+        return False
+
+    try:
+        with open(filepath, "r", encoding="utf-8") as f:
+            data = json.load(f)
+
+        if not isinstance(data, list) or len(data) == 0:
+            return False
+
+        # 检查第一个元素是否有必要字段
+        first = data[0]
+        required = ["drug_id", "name", "sections"]
+        return all(k in first for k in required)
+
+    except (json.JSONDecodeError, Exception):
+        return False
+
+
+async def main():
+    load_env()
+
+    data_dir = Path(__file__).resolve().parent / "data"
+    json_files = sorted(data_dir.glob("*.json"))
+
+    print("=" * 60)
+    print("📂 扫描数据目录:", data_dir)
+    print(f"   发现 {len(json_files)} 个 JSON 文件")
+    print("=" * 60)
+
+    compatible = []
+    skipped = []
+
+    for f in json_files:
+        if is_compatible_drug_data(str(f)):
+            compatible.append(f)
+            print(f"   ✅ {f.name} — 兼容,将导入")
+        else:
+            skipped.append(f)
+            print(f"   ⏭️  {f.name} — 跳过(非药品数据格式)")
+
+    if not compatible:
+        print("\n❌ 没有找到可导入的数据文件!")
+        print("   需要包含 drug_id, name, sections, source 字段的 JSON 数组")
+        return
+
+    print(f"\n📦 共 {len(compatible)} 个文件待导入")
+    print("-" * 60)
+
+    total_drugs = 0
+    total_chunks = 0
+
+    for f in compatible:
+        try:
+            print(f"\n🚀 正在导入: {f.name} ...")
+            await ingest_drugs(str(f))
+            # ingest_drugs 已经打印了统计信息
+
+            # 数一下这个文件有多少条
+            with open(f, "r", encoding="utf-8") as fh:
+                data = json.load(fh)
+            drug_count = len(data) if isinstance(data, list) else 0
+            total_drugs += drug_count
+
+        except Exception as e:
+            print(f"   ❌ 导入失败: {e}")
+            continue
+
+    print("\n" + "=" * 60)
+    print(f"🎉 全部导入完成!共处理 {len(compatible)} 个文件")
+    print("=" * 60)
+
+    # 提示:如果有旧数据没有 vec,执行修复 SQL
+    print("""
+💡 提示:如果之前导入的数据缺少 vec 列(pgvector),
+   请在 psql 中执行以下 SQL 修复:
+
+   UPDATE drug_chunks
+   SET vec = (embedding::text)::vector
+   WHERE vec IS NULL AND embedding IS NOT NULL;
+""")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 19 - 7
data-pipeline/ingest.py

@@ -31,7 +31,14 @@ load_env()
 API_KEY = os.environ.get("QWEN_API_KEY", "")
 EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
 EMBEDDING_MODEL = "text-embedding-v3"
-DB_URL = "postgresql+asyncpg://postgres:change-me@localhost:5432/pharmacopoeia"
+
+# 从环境变量读取数据库连接信息
+PG_HOST = os.environ.get("POSTGRES_HOST", "localhost")
+PG_PORT = os.environ.get("POSTGRES_PORT", "5432")
+PG_DB = os.environ.get("POSTGRES_DB", "pharmacopoeia")
+PG_USER = os.environ.get("POSTGRES_USER", "postgres")
+PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
+DB_URL = f"postgresql+asyncpg://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
 
 async def get_embeddings(texts: list[str], text_type: str = "document") -> list[list[float]]:
     async with httpx.AsyncClient(timeout=60) as client:
@@ -101,8 +108,8 @@ async def ingest_drugs(json_path: str):
         for idx, (row, vec) in enumerate(zip(chunk_rows, all_vectors)):
             await conn.execute(
                 text("""
-                    INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding)
-                    VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding)
+                    INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding, vec)
+                    VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding, :vec)
                 """),
                 {
                     "drug_id": row["drug_id"],
@@ -111,6 +118,7 @@ async def ingest_drugs(json_path: str):
                     "source": row["source"],
                     "chunk_index": idx,
                     "embedding": json.dumps(vec),
+                    "vec": f"[{','.join(str(v) for v in vec)}]",
                 },
             )
             count += 1
@@ -145,8 +153,12 @@ async def ingest_drugs(json_path: str):
 
 
 if __name__ == "__main__":
-    sample_path = os.path.join(os.path.dirname(__file__), "data", "sample_drugs.json")
-    if not os.path.exists(sample_path):
-        print(f"❌ 找不到数据文件: {sample_path}")
+    if len(sys.argv) > 1:
+        json_path = sys.argv[1]
+    else:
+        json_path = os.path.join(os.path.dirname(__file__), "data", "sample_drugs.json")
+
+    if not os.path.exists(json_path):
+        print(f"❌ 找不到数据文件: {json_path}")
         sys.exit(1)
-    asyncio.run(ingest_drugs(sample_path))
+    asyncio.run(ingest_drugs(json_path))