diff --git a/configs/workbench.local.json b/configs/workbench.local.json index dd9ca71..c294f1d 100644 --- a/configs/workbench.local.json +++ b/configs/workbench.local.json @@ -5,6 +5,11 @@ "max_open_conns": 15, "max_idle_conns": 5 }, + "core_mysql": { + "dsn": "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local", + "max_open_conns": 15, + "max_idle_conns": 5 + }, "redis": { "addr": "127.0.0.1:6379", "db": 0, diff --git a/configs/workbench.test.json b/configs/workbench.test.json index 21171c7..efd6a58 100644 --- a/configs/workbench.test.json +++ b/configs/workbench.test.json @@ -1,6 +1,9 @@ { "listen": ":6061", "mysql": { + "dsn": "root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local" + }, + "core_mysql": { "dsn": "root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local" }, "redis": { diff --git a/internal/ai/service.go b/internal/ai/service.go index 4e94108..2a7a16c 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -198,6 +198,7 @@ func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, d if err != nil { return DocumentResult{}, err } + usage := usageFromResponse(resp) var out struct { Title string `json:"title"` Summary string `json:"summary"` @@ -206,7 +207,7 @@ func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, d Sections []DocumentSection `json:"sections"` } if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { - return DocumentResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) + return fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出格式异常,已使用兜底文档。"), nil } out.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档")) out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown) @@ -217,7 +218,7 @@ func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, d out.ContentPlain = plainFromSections(out.Sections) } if out.ContentMarkdown == "" { - return DocumentResult{}, ErrBadOutput + return fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出缺少文档正文,已使用兜底文档。"), nil } return DocumentResult{ Title: out.Title, @@ -225,10 +226,47 @@ func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, d ContentPlain: strings.TrimSpace(out.ContentPlain), Sections: out.Sections, Summary: strings.TrimSpace(out.Summary), - Usage: usageFromResponse(resp), + Usage: usage, }, nil } +func fallbackDocument(sourceText, titleHint, documentType string, usage Usage, reason string) DocumentResult { + title := firstNonEmpty(titleHint, documentTypeTitle(documentType), excerptPlain(sourceText, 30), "智能文档") + source := strings.TrimSpace(sourceText) + if source == "" { + source = "用户未提供有效原文。" + } + sections := []DocumentSection{ + {Heading: "处理说明", Content: reason}, + {Heading: "用户输入", Content: source}, + {Heading: "后续建议", Content: "建议检查原始输入是否完整,或重新发起生成以获取更完整的智能文档。"}, + } + contentMarkdown := markdownFromSections(sections) + return DocumentResult{ + Title: title, + ContentMarkdown: contentMarkdown, + ContentPlain: plainFromSections(sections), + Sections: sections, + Summary: excerptPlain(source, 120), + Usage: usage, + } +} + +func documentTypeTitle(documentType string) string { + switch strings.TrimSpace(documentType) { + case "meeting_minutes": + return "会议纪要" + case "speech": + return "演讲稿" + case "proposal": + return "方案文档" + case "summary": + return "内容总结" + default: + return "" + } +} + func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) { if s == nil || s.client == nil { return TableAnalysisResult{}, ErrNotConfigured diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index 188c7e5..839c21a 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -27,3 +27,28 @@ func TestFallbackTableAnalysisUnparsedProfile(t *testing.T) { t.Fatalf("last metric value=%q, want 未解析", got) } } + +func TestFallbackDocumentBuildsDisplayableContent(t *testing.T) { + result := fallbackDocument( + "生成一个竞争销售部门副科长职位的演讲稿", + "竞争销售部门副科长职位演讲稿", + "meeting_minutes", + Usage{Model: "deepseek-flash"}, + "AI 输出格式异常,已使用兜底文档。", + ) + if result.Title != "竞争销售部门副科长职位演讲稿" { + t.Fatalf("Title=%q, want title hint", result.Title) + } + if strings.TrimSpace(result.ContentMarkdown) == "" { + t.Fatal("ContentMarkdown is empty") + } + if !strings.Contains(result.ContentMarkdown, "AI 输出格式异常") { + t.Fatalf("ContentMarkdown=%q, want fallback reason", result.ContentMarkdown) + } + if !strings.Contains(result.ContentPlain, "生成一个竞争销售部门副科长职位的演讲稿") { + t.Fatalf("ContentPlain=%q, want source text", result.ContentPlain) + } + if len(result.Sections) != 3 { + t.Fatalf("Sections len=%d, want 3", len(result.Sections)) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2642dba..3660f1a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -157,6 +157,8 @@ func applyEnv(cfg *Config) { setInt(&cfg.MySQL.MaxOpenConns, "IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS") setInt(&cfg.MySQL.MaxIdleConns, "IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS") setString(&cfg.CoreMySQL.DSN, "IM_WORKBENCH_CORE_MYSQL_DSN") + setInt(&cfg.CoreMySQL.MaxOpenConns, "IM_WORKBENCH_CORE_MYSQL_MAX_OPEN_CONNS") + setInt(&cfg.CoreMySQL.MaxIdleConns, "IM_WORKBENCH_CORE_MYSQL_MAX_IDLE_CONNS") if strings.TrimSpace(cfg.CoreMySQL.DSN) == "" { cfg.CoreMySQL.DSN = cfg.MySQL.DSN } diff --git a/internal/store/ai.go b/internal/store/ai.go index e4d415b..ccc5091 100644 --- a/internal/store/ai.go +++ b/internal/store/ai.go @@ -396,7 +396,11 @@ func (s *Store) SaveCompletedFileUpload(ctx context.Context, file CompletedFileU if now.IsZero() { now = time.Now().UTC().Round(time.Millisecond) } - _, err := s.db.ExecContext(ctx, `INSERT INTO im_fileuploads + db := s.db + if s.core != nil && s.core.db != nil { + db = s.core.db + } + _, err := db.ExecContext(ctx, `INSERT INTO im_fileuploads (tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)`, s.tenant(), file.ID, now, now, formatInt64(file.UserID), 1, file.MIMEType, file.Size, file.ETag, file.Location) diff --git a/internal/store/mysql.go b/internal/store/mysql.go index e324ba8..fa77b5c 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -172,10 +172,13 @@ func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]Workbench } func (s *Store) MeetingUnreadCount(ctx context.Context, userID int64) (int, error) { + if s.core == nil || s.core.db == nil { + return 0, nil + } var count int query := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE tenant_id=? AND user_id=? AND read_at IS NULL AND event_type LIKE 'meeting.%%'`, tenantSystemNotificationsTable(s.tenant())) - err := s.db.GetContext(ctx, &count, query, s.tenant(), userID) + err := s.core.db.GetContext(ctx, &count, query, s.tenant(), userID) if isMissingTable(err) { return 0, nil } diff --git a/scripts/migrate_workbench_tables.sh b/scripts/migrate_workbench_tables.sh new file mode 100755 index 0000000..8bb9f95 --- /dev/null +++ b/scripts/migrate_workbench_tables.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + migrate_workbench_tables.sh --host HOST --port PORT --user USER --password PASSWORD --source meeting_im --target workbench [--backup-dir DIR] + +Migrates im_workbench-owned tables from the shared IM database into the standalone workbench database. +The target tables are dropped and recreated from the source dump. Source and target table dumps are +written before any target changes. +EOF +} + +HOST="" +PORT="3306" +USER="" +PASSWORD="" +SOURCE_DB="meeting_im" +TARGET_DB="workbench" +BACKUP_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --user) USER="$2"; shift 2 ;; + --password) PASSWORD="$2"; shift 2 ;; + --source) SOURCE_DB="$2"; shift 2 ;; + --target) TARGET_DB="$2"; shift 2 ;; + --backup-dir) BACKUP_DIR="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ -z "$HOST" || -z "$USER" || -z "$PASSWORD" || -z "$SOURCE_DB" || -z "$TARGET_DB" ]]; then + usage >&2 + exit 2 +fi + +TABLES=( + im_workbench_apps + im_org_departments + im_org_members + im_org_visibility_overrides + im_attendance_rules + im_attendance_shifts + im_attendance_records + im_attendance_checkins + im_attendance_appeals + im_meetings + im_meeting_participants + im_meeting_note_files + im_daily_reports + im_monthly_reports + im_mailbox_config + im_mailbox_submissions + im_feedback_config + im_feedback_submissions + wb_ai_capability_config + wb_ai_document + wb_ai_idempotency_record + wb_ai_meeting_summary + wb_ai_prompt_template + wb_ai_record + wb_ai_table_analysis + wb_ai_task + wb_ai_task_step + wb_ai_usage_log +) + +timestamp="$(date +%Y%m%d%H%M%S)" +if [[ -z "$BACKUP_DIR" ]]; then + BACKUP_DIR="./tmp/workbench_migration_${HOST}_${PORT}_${timestamp}" +fi +mkdir -p "$BACKUP_DIR" + +MYSQL=(mysql -h "$HOST" -P "$PORT" -u "$USER" "-p$PASSWORD" --default-character-set=utf8mb4) +MYSQLDUMP=(mysqldump -h "$HOST" -P "$PORT" -u "$USER" "-p$PASSWORD" --default-character-set=utf8mb4 --single-transaction --skip-add-locks --set-gtid-purged=OFF) + +echo "backup dir: $BACKUP_DIR" +echo "checking source tables..." +"${MYSQL[@]}" -N -e "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='${SOURCE_DB}'" > "$BACKUP_DIR/${SOURCE_DB}_tables.txt" + +existing_tables=() +for table in "${TABLES[@]}"; do + if grep -qx "$table" "$BACKUP_DIR/${SOURCE_DB}_tables.txt"; then + existing_tables+=("$table") + else + echo "warning: source table ${SOURCE_DB}.${table} does not exist, skipping" >&2 + fi +done + +if [[ ${#existing_tables[@]} -eq 0 ]]; then + echo "no source tables found" >&2 + exit 1 +fi + +echo "creating target database if needed..." +"${MYSQL[@]}" -e "CREATE DATABASE IF NOT EXISTS \`${TARGET_DB}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci" + +echo "backing up source tables..." +"${MYSQLDUMP[@]}" "$SOURCE_DB" "${existing_tables[@]}" > "$BACKUP_DIR/${SOURCE_DB}_workbench_tables.sql" + +target_existing=() +for table in "${existing_tables[@]}"; do + if "${MYSQL[@]}" -N -e "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${TARGET_DB}' AND TABLE_NAME='${table}'" | grep -qx "1"; then + target_existing+=("$table") + fi +done +if [[ ${#target_existing[@]} -gt 0 ]]; then + echo "backing up existing target tables..." + "${MYSQLDUMP[@]}" "$TARGET_DB" "${target_existing[@]}" > "$BACKUP_DIR/${TARGET_DB}_workbench_tables_before.sql" +fi + +drop_sql="SET FOREIGN_KEY_CHECKS=0;" +for table in "${existing_tables[@]}"; do + drop_sql+="DROP TABLE IF EXISTS \`${TARGET_DB}\`.\`${table}\`;" +done +drop_sql+="SET FOREIGN_KEY_CHECKS=1;" + +echo "dropping target tables..." +"${MYSQL[@]}" -e "$drop_sql" + +echo "importing source dump into target..." +sed -E \ + -e "s/^\s*CREATE DATABASE .*//" \ + -e "s/^\s*USE \`${SOURCE_DB}\`;//" \ + -e "s/^\s*USE ${SOURCE_DB};//" \ + -e "/^\s*LOCK TABLES /d" \ + -e "/^\s*UNLOCK TABLES;/d" \ + "$BACKUP_DIR/${SOURCE_DB}_workbench_tables.sql" | "${MYSQL[@]}" "$TARGET_DB" + +echo "row count comparison:" +printf "%-34s %12s %12s\n" "table" "source" "target" +failed=0 +for table in "${existing_tables[@]}"; do + src_count="$("${MYSQL[@]}" -N -e "SELECT COUNT(*) FROM \`${SOURCE_DB}\`.\`${table}\`")" + dst_count="$("${MYSQL[@]}" -N -e "SELECT COUNT(*) FROM \`${TARGET_DB}\`.\`${table}\`")" + printf "%-34s %12s %12s\n" "$table" "$src_count" "$dst_count" + if [[ "$src_count" != "$dst_count" ]]; then + failed=1 + fi +done | tee "$BACKUP_DIR/row_counts.txt" + +if [[ "$failed" -ne 0 ]]; then + echo "row count mismatch" >&2 + exit 1 +fi + +echo "migration complete"