Quellcode durchsuchen

优化日志查询和ip查询

liuchengsen vor 4 Wochen
Ursprung
Commit
0632d83fb9

+ 16 - 4
backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java

@@ -233,12 +233,24 @@ public class AdminKnowledgeController {
         return ResponseEntity.ok(service.updateQuickAsk(id, label, question, sortOrder, isActive));
     }
 
-    @DeleteMapping("/quick-asks/{id}")
-    public ResponseEntity<Map<String, Object>> deleteQuickAsk(@PathVariable int id) {
-        return ResponseEntity.ok(service.deleteQuickAsk(id));
+   @DeleteMapping("/quick-asks/{id}")
+   public ResponseEntity<Map<String, Object>> deleteQuickAsk(@PathVariable int id) {
+       return ResponseEntity.ok(service.deleteQuickAsk(id));
+   }
+
+    @PutMapping("/quick-asks/reorder")
+    public ResponseEntity<Map<String, Object>> reorderQuickAsks(@RequestBody List<Map<String, Object>> items) {
+        return ResponseEntity.ok(service.reorderQuickAsks(items));
     }
 
-    // ==================== Helpers ====================
+    @GetMapping("/quick-asks/audit-log")
+    public ResponseEntity<Map<String, Object>> listQuickAskAuditLog(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(service.listQuickAskAuditLog(page, pageSize));
+    }
+
+   // ==================== Helpers ====================
 
     private int parseCount(ImportRequest req) {
         try {

+ 2 - 1
backend-java/src/main/java/com/pharmacopoeia/controller/YaodianQuickAskController.java

@@ -1,5 +1,6 @@
 package com.pharmacopoeia.controller;
 
+import com.pharmacopoeia.entity.QuickAsk;
 import com.pharmacopoeia.service.AdminKnowledgeService;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
@@ -22,7 +23,7 @@ public class YaodianQuickAskController {
     }
 
     @GetMapping("/quick-asks")
-    public ResponseEntity<List<Map<String, Object>>> listQuickAsks() {
+    public ResponseEntity<List<QuickAsk>> listQuickAsks() {
         return ResponseEntity.ok(service.listQuickAsks());
     }
 }

+ 56 - 0
backend-java/src/main/java/com/pharmacopoeia/entity/QuickAsk.java

@@ -0,0 +1,56 @@
+package com.pharmacopoeia.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.Instant;
+
+@Entity
+@Table(name = "quick_asks")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class QuickAsk {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Integer id;
+
+    @Column(nullable = false, length = 64)
+    private String label;
+
+    @Column(nullable = false, length = 256)
+    private String question;
+
+    @Column(name = "sort_order", nullable = false)
+    @Builder.Default
+    private Integer sortOrder = 0;
+
+    @Column(name = "is_active", nullable = false)
+    @Builder.Default
+    private Boolean isActive = true;
+
+    @Column(name = "created_at", updatable = false)
+    private Instant createdAt;
+
+    @Column(name = "updated_at")
+    private Instant updatedAt;
+
+    @Column(name = "created_by", length = 255)
+    private String createdBy;
+
+    @Column(name = "updated_by", length = 255)
+    private String updatedBy;
+
+    @PrePersist
+    void onCreate() {
+        if (createdAt == null) createdAt = Instant.now();
+        if (updatedAt == null) updatedAt = Instant.now();
+    }
+
+    @PreUpdate
+    void onUpdate() {
+        updatedAt = Instant.now();
+    }
+}

+ 50 - 0
backend-java/src/main/java/com/pharmacopoeia/entity/QuickAskAuditLog.java

@@ -0,0 +1,50 @@
+package com.pharmacopoeia.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.Instant;
+
+@Entity
+@Table(name = "quick_ask_audit_log")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class QuickAskAuditLog {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "quick_ask_id", nullable = false)
+    private Integer quickAskId;
+
+    @Column(nullable = false, length = 16)
+    private String action;
+
+    @Column(length = 64)
+    private String label;
+
+    @Column(length = 256)
+    private String question;
+
+    @Column(name = "sort_order")
+    @Builder.Default
+    private Integer sortOrder = 0;
+
+    @Column(name = "is_active")
+    @Builder.Default
+    private Boolean isActive = true;
+
+    @Column(nullable = false, length = 255)
+    private String operator;
+
+    @Column(name = "created_at", updatable = false)
+    private Instant createdAt;
+
+    @PrePersist
+    void onCreate() {
+        if (createdAt == null) createdAt = Instant.now();
+    }
+}

+ 13 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/QuickAskAuditLogRepository.java

@@ -0,0 +1,13 @@
+package com.pharmacopoeia.repository;
+
+import com.pharmacopoeia.entity.QuickAskAuditLog;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface QuickAskAuditLogRepository extends JpaRepository<QuickAskAuditLog, Long> {
+
+    Page<QuickAskAuditLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
+}

+ 28 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/QuickAskRepository.java

@@ -0,0 +1,28 @@
+package com.pharmacopoeia.repository;
+
+import com.pharmacopoeia.entity.QuickAsk;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Repository
+public interface QuickAskRepository extends JpaRepository<QuickAsk, Integer> {
+
+    List<QuickAsk> findAllByOrderBySortOrderAscIdAsc();
+
+    List<QuickAsk> findByIsActiveTrueOrderBySortOrderAscIdAsc();
+
+    long countByLabel(String label);
+
+    long countByLabelAndIdNot(String label, Integer id);
+
+    @Modifying
+    @Transactional
+    @Query("UPDATE QuickAsk q SET q.sortOrder = :sortOrder, q.updatedBy = :updatedBy WHERE q.id = :id")
+    int updateSortOrder(@Param("id") Integer id, @Param("sortOrder") Integer sortOrder, @Param("updatedBy") String updatedBy);
+}

+ 116 - 26
backend-java/src/main/java/com/pharmacopoeia/service/AdminKnowledgeService.java

@@ -8,10 +8,16 @@ import com.pharmacopoeia.entity.Question;
 import com.pharmacopoeia.repository.DrugRepository;
 import com.pharmacopoeia.repository.KnowledgePointRepository;
 import com.pharmacopoeia.repository.QuestionRepository;
+import com.pharmacopoeia.repository.QuickAskRepository;
+import com.pharmacopoeia.repository.QuickAskAuditLogRepository;
+import com.pharmacopoeia.entity.QuickAsk;
+import com.pharmacopoeia.entity.QuickAskAuditLog;
 import org.springframework.data.domain.PageRequest;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.*;
 
@@ -24,13 +30,20 @@ public class AdminKnowledgeService {
     private final JdbcTemplate jdbc;
     private final ObjectMapper mapper = new ObjectMapper();
 
+    private final QuickAskRepository quickAskRepository;
+    private final QuickAskAuditLogRepository auditLogRepository;
+
     public AdminKnowledgeService(DrugRepository drugRepository,
                                   KnowledgePointRepository knowledgePointRepository,
                                   QuestionRepository questionRepository,
+                                  QuickAskRepository quickAskRepository,
+                                  QuickAskAuditLogRepository auditLogRepository,
                                   JdbcTemplate jdbc) {
         this.drugRepository = drugRepository;
         this.knowledgePointRepository = knowledgePointRepository;
         this.questionRepository = questionRepository;
+        this.quickAskRepository = quickAskRepository;
+        this.auditLogRepository = auditLogRepository;
         this.jdbc = jdbc;
     }
 
@@ -358,47 +371,124 @@ public class AdminKnowledgeService {
     // ==================== 快捷提问标签管理 ====================
 
     /** 获取所有激活的标签,按 sort_order 排序(公开接口,无需鉴权) */
-    public List<Map<String, Object>> listQuickAsks() {
-        return jdbc.queryForList(
-                "SELECT id, label, question, sort_order FROM quick_asks WHERE is_active = TRUE ORDER BY sort_order, id");
+    public List<QuickAsk> listQuickAsks() {
+        return quickAskRepository.findByIsActiveTrueOrderBySortOrderAscIdAsc();
     }
 
     /** 管理端:获取所有标签(含禁用) */
-    public List<Map<String, Object>> listAllQuickAsks() {
-        return jdbc.queryForList(
-                "SELECT id, label, question, sort_order, is_active, created_at FROM quick_asks ORDER BY sort_order, id");
+    public List<QuickAsk> listAllQuickAsks() {
+        return quickAskRepository.findAllByOrderBySortOrderAscIdAsc();
     }
 
     /** 新增标签 */
+    @Transactional
     public Map<String, Object> createQuickAsk(String label, String question, int sortOrder) {
-        int rows = jdbc.update(
-                "INSERT INTO quick_asks (label, question, sort_order) VALUES (?, ?, ?)",
-                label, question, sortOrder);
-        return Map.of("ok", rows > 0, "message", rows > 0 ? "已创建" : "创建失败");
+        if (label == null || label.isBlank()) {
+            return Map.of("ok", false, "message", "请输入推荐位文字");
+        }
+        String trimmedLabel = label.trim();
+        if (trimmedLabel.length() < 2 || trimmedLabel.length() > 20) {
+            return Map.of("ok", false, "message", "推荐位文字长度需在2-20个字符之间");
+        }
+        if (quickAskRepository.count() >= 6) {
+            return Map.of("ok", false, "message", "最多只能添加6个推荐位");
+        }
+        if (quickAskRepository.countByLabel(trimmedLabel) > 0) {
+            return Map.of("ok", false, "message", "推荐位文字不能重复");
+        }
+        String operator = getCurrentOperator();
+        QuickAsk qa = QuickAsk.builder()
+                .label(trimmedLabel)
+                .question(question != null ? question.trim() : trimmedLabel)
+                .sortOrder(sortOrder)
+                .isActive(true)
+                .createdBy(operator)
+                .build();
+        qa = quickAskRepository.save(qa);
+        auditLogRepository.save(QuickAskAuditLog.builder()
+                .quickAskId(qa.getId()).action("CREATE")
+                .label(qa.getLabel()).question(qa.getQuestion())
+                .sortOrder(qa.getSortOrder()).isActive(qa.getIsActive())
+                .operator(operator).build());
+        return Map.of("ok", true, "id", qa.getId(), "message", "已创建");
     }
 
     /** 更新标签 */
+    @Transactional
     public Map<String, Object> updateQuickAsk(int id, String label, String question, Integer sortOrder, Boolean isActive) {
-        var sql = new StringBuilder("UPDATE quick_asks SET ");
-        var params = new ArrayList<>();
-        if (label != null) { sql.append("label = ?, "); params.add(label); }
-        if (question != null) { sql.append("question = ?, "); params.add(question); }
-        if (sortOrder != null) { sql.append("sort_order = ?, "); params.add(sortOrder); }
-        if (isActive != null) { sql.append("is_active = ?, "); params.add(isActive); }
-        if (params.isEmpty()) return Map.of("ok", false, "message", "无修改字段");
-
-        sql.setLength(sql.length() - 2); // 去掉末尾 ", "
-        sql.append(" WHERE id = ?");
-        params.add(id);
-
-        int rows = jdbc.update(sql.toString(), params.toArray());
-        return Map.of("ok", rows > 0, "message", rows > 0 ? "已更新" : "未找到");
+        var opt = quickAskRepository.findById(id);
+        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到");
+        QuickAsk qa = opt.get();
+        if (label != null && !label.isBlank()) {
+            String trimmedLabel = label.trim();
+            if (quickAskRepository.countByLabelAndIdNot(trimmedLabel, id) > 0) {
+                return Map.of("ok", false, "message", "推荐位文字不能重复");
+            }
+            qa.setLabel(trimmedLabel);
+        }
+        if (question != null) qa.setQuestion(question.trim());
+        if (sortOrder != null) qa.setSortOrder(sortOrder);
+        if (isActive != null) qa.setIsActive(isActive);
+        String operator = getCurrentOperator();
+        qa.setUpdatedBy(operator);
+        quickAskRepository.save(qa);
+        auditLogRepository.save(QuickAskAuditLog.builder()
+                .quickAskId(id).action("UPDATE")
+                .label(qa.getLabel()).question(qa.getQuestion())
+                .sortOrder(qa.getSortOrder()).isActive(qa.getIsActive())
+                .operator(operator).build());
+        return Map.of("ok", true, "id", id, "message", "已更新");
     }
 
     /** 删除标签 */
+    @Transactional
     public Map<String, Object> deleteQuickAsk(int id) {
-        int rows = jdbc.update("DELETE FROM quick_asks WHERE id = ?", id);
-        return Map.of("ok", rows > 0, "message", rows > 0 ? "已删除" : "未找到");
+        var opt = quickAskRepository.findById(id);
+        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到");
+        QuickAsk qa = opt.get();
+        String operator = getCurrentOperator();
+        auditLogRepository.save(QuickAskAuditLog.builder()
+                .quickAskId(id).action("DELETE")
+                .label(qa.getLabel()).question(qa.getQuestion())
+                .sortOrder(qa.getSortOrder()).isActive(qa.getIsActive())
+                .operator(operator).build());
+        quickAskRepository.delete(qa);
+        return Map.of("ok", true, "message", "已删除");
+    }
+
+    // ==================== ?????? ====================
+
+    public Map<String, Object> reorderQuickAsks(List<Map<String, Object>> items) {
+        String operator = getCurrentOperator();
+        int updated = 0;
+        for (Map<String, Object> item : items) {
+            int id = ((Number) item.get("id")).intValue();
+            int sortOrder = ((Number) item.get("sort_order")).intValue();
+            updated += quickAskRepository.updateSortOrder(id, sortOrder, operator);
+        }
+        return Map.of("ok", updated > 0, "message", "已更新 " + updated + " 条排序");
+    }
+
+    private void writeAuditLog(int quickAskId, String action, String operator,
+                               String label, String question, Integer sortOrder, Boolean isActive) {
+        auditLogRepository.save(QuickAskAuditLog.builder()
+                .quickAskId(quickAskId).action(action)
+                .label(label).question(question)
+                .sortOrder(sortOrder != null ? sortOrder : 0)
+                .isActive(isActive != null ? isActive : true)
+                .operator(operator).build());
+    }
+
+    public Map<String, Object> listQuickAskAuditLog(int page, int pageSize) {
+        var pageable = org.springframework.data.domain.PageRequest.of(page - 1, pageSize);
+        var result = auditLogRepository.findAllByOrderByCreatedAtDesc(pageable);
+        return Map.of(
+                "items", result.getContent(),
+                "page", page,
+                "page_size", pageSize,
+                "total", result.getTotalElements(),
+                "total_pages", result.getTotalPages()
+        );
     }
 
     // ==================== Helpers ====================

+ 306 - 0
data-pipeline/extract_images.py

@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""
+Standalone image extraction script for 2025 Pharmacopoeia DOCX files.
+===== ======== ========= ====== === ===== ============== === ======
+
+Extracts all embedded images from DOCX files and generates a
+drug-image mapping manifest (drug_images.json).
+
+Key features:
+- Monkey-patches docx_ingest config so images go to /opt/static/images/
+- Does NOT write to PostgreSQL or call any embedding / vectorization API
+- Tracks which section (性状/鉴别/检查/…) each image appears in
+- Falls back to filesystem scan for images the inline-parser may have missed
+- Generates a comprehensive drug_images.json manifest
+
+Environment variables:
+  DOCX_SOURCE_DIR         – root of the DOCX tree (default: /opt/2025)
+  EXTRACT_IMAGES_DIR      – where to write images  (default: /opt/static/images)
+  EXTRACT_IMAGE_URL_PREFIX – public URL prefix     (default: /images/)
+  EXTRACT_MANIFEST_FILE   – path for the JSON manifest (default: …)
+
+Author: auto-generated via Claude Code (2026-07-28)
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import sys
+import time
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+# 1.  load .env  (replicated from docx_ingest.py so we work before importing it)
+# ---------------------------------------------------------------------------
+def _load_env() -> None:
+    env_file = Path(__file__).resolve().parent.parent / ".env"
+    if not env_file.exists():
+        return
+    with open(env_file, encoding="utf-8") as fh:
+        for line in fh:
+            line = line.strip()
+            if not line or line.startswith("#") or "=" not in line:
+                continue
+            key, _, val = line.partition("=")
+            os.environ.setdefault(key.strip(), val.strip())
+
+
+_load_env()
+
+# ---------------------------------------------------------------------------
+# 2.  Configuration
+# ---------------------------------------------------------------------------
+SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
+IMAGES_DIR = os.environ.get("EXTRACT_IMAGES_DIR", "/opt/static/images")
+IMAGE_URL_PREFIX = os.environ.get("EXTRACT_IMAGE_URL_PREFIX", "/images/")
+MANIFEST_FILE = os.environ.get(
+    "EXTRACT_MANIFEST_FILE",
+    os.path.join(IMAGES_DIR, "drug_images.json"),
+)
+
+# ---------------------------------------------------------------------------
+# 3.  Monkey-patch *before* calling any docx_ingest function.
+#     Python resolves module-level globals at call-time, so mutating the
+#     module dict after import is enough to redirect _extract_images.
+# ---------------------------------------------------------------------------
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import docx_ingest  # noqa: E402
+
+docx_ingest.IMAGES_DIR = IMAGES_DIR
+docx_ingest.IMAGE_URL_PREFIX = IMAGE_URL_PREFIX
+
+# Convenience aliases — these are the SAME function objects,
+# they just happen to use our overridden globals when called.
+parse_docx = docx_ingest.parse_docx
+find_docx_files = docx_ingest.find_docx_files
+
+# ---------------------------------------------------------------------------
+# 4.  Image -> section helpers
+# ---------------------------------------------------------------------------
+IMG_TAG_RE = re.compile(r'<img\s+src="([^"]+)"[^>]*/?>', re.IGNORECASE)
+
+# Characters that need sanitising for filesystem-safe drug names
+_RE_UNSAFE = re.compile(r'[\s()() /\\:?*"<>|]+')
+
+
+def _safe_drug_name(name: str) -> str:
+    """Return a filesystem-safe version of *name* (used as image filename prefix)."""
+    return _RE_UNSAFE.sub("_", name).strip("_")
+
+
+def _collect_images_from_sections(
+    sections: dict[str, str],
+) -> list[dict[str, str]]:
+    """Walk section text looking for ``&lt;img src="…"&gt;`` tags.
+
+    Returns a flat list of ``{section, filename, url}`` dicts.
+    """
+    records: list[dict[str, str]] = []
+    for sec_key, sec_text in sections.items():
+        for m in IMG_TAG_RE.finditer(sec_text):
+            url = m.group(1)
+            filename = url.rsplit("/", 1)[-1] if "/" in url else url
+            records.append({"section": sec_key, "filename": filename, "url": url})
+    return records
+
+
+def _find_orphan_images(
+    safe_name: str,
+    images_dir: str,
+    already_found: set[str],
+) -> list[dict[str, str]]:
+    """Filesystem 兜底:找 &lt;img&gt; 标签中没有覆盖到的图片。
+
+    Some images sit in the same paragraph as text (line 214 of docx_ingest.py
+    has the ``if not p_text`` guard), so the inline parser does not emit an
+    ``&lt;img&gt;`` tag for them.  We catch those here by matching file names.
+    """
+    if not os.path.isdir(images_dir):
+        return []
+
+    prefix = safe_name + "_"
+    orphans: list[dict[str, str]] = []
+    try:
+        for fname in os.listdir(images_dir):
+            if fname.startswith(prefix) and fname not in already_found:
+                orphans.append(
+                    {
+                        "section": "未分类",
+                        "filename": fname,
+                        "url": IMAGE_URL_PREFIX.rstrip("/") + "/" + fname,
+                    }
+                )
+    except OSError:
+        pass
+    return orphans
+
+
+def _group_by_section(
+    flat: list[dict[str, str]],
+) -> list[dict]:
+    """Group a flat list of ``{section, filename, url}`` by section key."""
+    groups: dict[str, list[dict[str, str]]] = {}
+    for rec in flat:
+        groups.setdefault(rec["section"], []).append(
+            {"filename": rec["filename"], "url": rec["url"]}
+        )
+    return [
+        {
+            "section": sec,
+            "images": imgs,
+            "count": len(imgs),
+        }
+        for sec, imgs in groups.items()
+    ]
+
+
+# ---------------------------------------------------------------------------
+# 5.  Main orchestrator
+# ---------------------------------------------------------------------------
+def main() -> None:
+    # re-read env in case caller uses different values after import  (rare,
+    # but harmless)
+    global IMAGES_DIR, IMAGE_URL_PREFIX, SOURCE_DIR, MANIFEST_FILE
+    IMAGES_DIR = os.environ.get("EXTRACT_IMAGES_DIR", "/opt/static/images")
+    IMAGE_URL_PREFIX = os.environ.get("EXTRACT_IMAGE_URL_PREFIX", "/images/")
+    SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
+    MANIFEST_FILE = os.environ.get(
+        "EXTRACT_MANIFEST_FILE",
+        os.path.join(IMAGES_DIR, "drug_images.json"),
+    )
+
+    # Apply any env-driven change back to docx_ingest
+    docx_ingest.IMAGES_DIR = IMAGES_DIR
+    docx_ingest.IMAGE_URL_PREFIX = IMAGE_URL_PREFIX
+
+    # Ensure directories exist
+    os.makedirs(IMAGES_DIR, exist_ok=True)
+    manifest_dir = os.path.dirname(MANIFEST_FILE) or "."
+    os.makedirs(manifest_dir, exist_ok=True)
+
+    print("=" * 60)
+    print("IMAGE EXTRACTION: Scanning DOCX files …")
+    print(f"  Source:      {SOURCE_DIR}")
+    print(f"  Images out:  {IMAGES_DIR}")
+    print(f"  URL prefix:  {IMAGE_URL_PREFIX}")
+    print(f"  Manifest:    {MANIFEST_FILE}")
+    print("=" * 60)
+
+    files = find_docx_files(SOURCE_DIR)
+    if not files:
+        print("No DOCX files found – nothing to do.")
+        return
+
+    print(f"Found {len(files)} DOCX file(s)")
+
+    # ── process every file ──────────────────────────────────────────────
+    drug_records: list[dict] = []
+    drugs_parsed = 0
+    drugs_with_images = 0
+    total_unique_images = 0
+    skipped = 0
+    t0 = time.time()
+
+    for i, fp in enumerate(files, 1):
+        entry = parse_docx(fp)  # _extract_images runs as a side-effect
+        if entry is None:
+            skipped += 1
+            continue
+
+        drugs_parsed += 1
+
+        # 4a – extract from <img> tags embedded in section text
+        section_images = _collect_images_from_sections(entry["sections"])
+        found_filenames = {r["filename"] for r in section_images}
+
+        # 4b – filesystem fallback for images that the inline parser missed
+        safe = _safe_drug_name(entry["name"])
+        orphans = _find_orphan_images(safe, IMAGES_DIR, found_filenames)
+        section_images.extend(orphans)
+
+        if not section_images:
+            continue
+
+        grouped = _group_by_section(section_images)
+        unique_count = len({r["filename"] for r in section_images})
+
+        drug_records.append(
+            {
+                "drug_id": entry["drug_id"],
+                "name": entry["name"],
+                "pinyin": entry.get("pinyin", ""),
+                "category": entry.get("category", ""),
+                "subcategory": entry.get("subcategory", ""),
+                "volume": entry["source"]["volume"],
+                "source_file": fp,
+                "total_image_count": unique_count,
+                "sections_with_images": grouped,
+            }
+        )
+        drugs_with_images += 1
+        total_unique_images += unique_count
+
+        if i % 500 == 0:
+            elapsed = time.time() - t0
+            rate = i / elapsed if elapsed > 0 else 0
+            print(
+                f"  … {i:>5d}/{len(files)}  |  "
+                f"{rate:5.1f} docs/s  |  "
+                f"{drugs_with_images} drugs with images  |  "
+                f"{total_unique_images} images"
+            )
+
+    # ── summary ─────────────────────────────────────────────────────────
+    elapsed = time.time() - t0
+
+    # rough disk usage
+    du_bytes = 0
+    try:
+        for dirpath, _dirnames, filenames in os.walk(IMAGES_DIR):
+            for fn in filenames:
+                fp = os.path.join(dirpath, fn)
+                try:
+                    du_bytes += os.path.getsize(fp)
+                except OSError:
+                    pass
+    except OSError:
+        pass
+
+    print()
+    print("=" * 60)
+    print("EXTRACTION COMPLETE")
+    print(f"  Files scanned:             {len(files)}")
+    print(f"  Parsed successfully:       {drugs_parsed}")
+    print(f"  Skipped (parse errors):    {skipped}")
+    print(f"  Drugs with images:         {drugs_with_images}")
+    print(f"  Total unique images:       {total_unique_images}")
+    print(f"  Disk usage:                {du_bytes / 1024 / 1024:.1f} MiB")
+    print(f"  Elapsed:                   {elapsed:.0f}s  "
+          f"({elapsed / 60:.1f} min)")
+    print("=" * 60)
+
+    # ── write manifest ──────────────────────────────────────────────────
+    manifest = {
+        "meta": {
+            "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+            "source_dir": SOURCE_DIR,
+            "images_dir": IMAGES_DIR,
+            "image_url_prefix": IMAGE_URL_PREFIX,
+            "total_drugs_parsed": drugs_parsed,
+            "total_drugs_with_images": drugs_with_images,
+            "total_unique_images": total_unique_images,
+            "skipped_files": skipped,
+        },
+        "drugs": drug_records,
+    }
+
+    with open(MANIFEST_FILE, "w", encoding="utf-8") as fh:
+        json.dump(manifest, fh, ensure_ascii=False, indent=2)
+
+    print(f"Manifest written to  {MANIFEST_FILE}")
+
+
+if __name__ == "__main__":
+    main()

+ 44 - 0
database/migrate_image_urls.sql

@@ -0,0 +1,44 @@
+-- ============================================================
+-- 图片 URL 迁移:/images/drugs/ → /images/
+-- ============================================================
+-- 执行方式:
+--   docker exec -i pharmacopoeia-pg psql -U postgres -d pharmacopoeia < database/migrate_image_urls.sql
+--
+-- 回滚方式:
+--   将下面 SQL 中的 /images/" 替换为 /images/drugs/" 再执行一遍
+-- ============================================================
+
+BEGIN;
+
+-- 1. 更新 drugs.sections (jsonb) 中的 <img> 标签 URL
+--    sections 是 jsonb,需要 text → regexp_replace → jsonb 来回转
+UPDATE drugs
+SET sections = regexp_replace(sections::text, '/images/drugs/', '/images/', 'g')::jsonb
+WHERE sections::text LIKE '%/images/drugs/%';
+
+-- 2. 更新 drug_chunks.content (text) 中的 <img> 标签 URL
+UPDATE drug_chunks
+SET content = replace(content, '/images/drugs/', '/images/')
+WHERE content LIKE '%/images/drugs/%';
+
+-- 3. 验证:确认无残留旧 URL
+DO $$
+DECLARE
+    drugs_old INT;
+    chunks_old INT;
+BEGIN
+    SELECT COUNT(*) INTO drugs_old FROM drugs
+    WHERE sections::text LIKE '%/images/drugs/%';
+    SELECT COUNT(*) INTO chunks_old FROM drug_chunks
+    WHERE content LIKE '%/images/drugs/%';
+
+    RAISE NOTICE '旧 URL 残留: drugs=%  chunks=%', drugs_old, chunks_old;
+
+    IF drugs_old > 0 OR chunks_old > 0 THEN
+        RAISE WARNING '仍有旧 URL 残留,请检查!';
+    ELSE
+        RAISE NOTICE '✅ 迁移完成,无旧 URL 残留';
+    END IF;
+END $$;
+
+COMMIT;

+ 30 - 0
database/migrate_quick_asks_v2.sql

@@ -0,0 +1,30 @@
+1-- ============================================================
+-- 快捷提问标签表 v2 迁移
+-- 1. 补充 updated_at / created_by / updated_by 字段
+-- 2. 新建审计日志表
+-- 执行: psql -h <host> -U <user> -d pharmacopoeia -f database/migrate_quick_asks_v2.sql
+-- ============================================================
+BEGIN;
+
+-- 补充字段(幂等)
+ALTER TABLE public.quick_asks ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
+ALTER TABLE public.quick_asks ADD COLUMN IF NOT EXISTS created_by varchar(255);
+ALTER TABLE public.quick_asks ADD COLUMN IF NOT EXISTS updated_by varchar(255);
+
+-- 审计日志表
+CREATE TABLE IF NOT EXISTS public.quick_ask_audit_log (
+    id SERIAL PRIMARY KEY,
+    quick_ask_id integer NOT NULL,
+    action varchar(16) NOT NULL,
+    label varchar(64),
+    question varchar(256),
+    sort_order integer DEFAULT 0,
+    is_active boolean DEFAULT true,
+    operator varchar(255) NOT NULL,
+    created_at timestamptz DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_qa_audit_action ON public.quick_ask_audit_log(action);
+CREATE INDEX IF NOT EXISTS idx_qa_audit_created_at ON public.quick_ask_audit_log(created_at DESC);
+
+COMMIT;

+ 2 - 2
deploy/nginx-pharmacopoeia.conf

@@ -48,9 +48,9 @@ server {
         access_log off;
     }
 
-    # 药品图片(DOCX 提取到 /images/drugs/)
+    # 药品图片(DOCX 提取,新路径 /opt/static/images/)
     location /images/ {
-        alias /opt/pharmacopoeia-ai/static/images/;
+        alias /opt/static/images/;
         expires 30d;
         add_header Cache-Control "public, immutable";
     }