fix: split workbench core mysql access

This commit is contained in:
richjohnrambo
2026-10-07 14:38:56 +08:00
parent 8d32376de8
commit c13fb72849
8 changed files with 237 additions and 5 deletions
+5
View File
@@ -5,6 +5,11 @@
"max_open_conns": 15, "max_open_conns": 15,
"max_idle_conns": 5 "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": { "redis": {
"addr": "127.0.0.1:6379", "addr": "127.0.0.1:6379",
"db": 0, "db": 0,
+3
View File
@@ -1,6 +1,9 @@
{ {
"listen": ":6061", "listen": ":6061",
"mysql": { "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" "dsn": "root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local"
}, },
"redis": { "redis": {
+41 -3
View File
@@ -198,6 +198,7 @@ func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, d
if err != nil { if err != nil {
return DocumentResult{}, err return DocumentResult{}, err
} }
usage := usageFromResponse(resp)
var out struct { var out struct {
Title string `json:"title"` Title string `json:"title"`
Summary string `json:"summary"` Summary string `json:"summary"`
@@ -206,7 +207,7 @@ func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, d
Sections []DocumentSection `json:"sections"` Sections []DocumentSection `json:"sections"`
} }
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { 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.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档"))
out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown) 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) out.ContentPlain = plainFromSections(out.Sections)
} }
if out.ContentMarkdown == "" { if out.ContentMarkdown == "" {
return DocumentResult{}, ErrBadOutput return fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出缺少文档正文,已使用兜底文档。"), nil
} }
return DocumentResult{ return DocumentResult{
Title: out.Title, Title: out.Title,
@@ -225,10 +226,47 @@ func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, d
ContentPlain: strings.TrimSpace(out.ContentPlain), ContentPlain: strings.TrimSpace(out.ContentPlain),
Sections: out.Sections, Sections: out.Sections,
Summary: strings.TrimSpace(out.Summary), Summary: strings.TrimSpace(out.Summary),
Usage: usageFromResponse(resp), Usage: usage,
}, nil }, 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) { func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) {
if s == nil || s.client == nil { if s == nil || s.client == nil {
return TableAnalysisResult{}, ErrNotConfigured return TableAnalysisResult{}, ErrNotConfigured
+25
View File
@@ -27,3 +27,28 @@ func TestFallbackTableAnalysisUnparsedProfile(t *testing.T) {
t.Fatalf("last metric value=%q, want 未解析", got) 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))
}
}
+2
View File
@@ -157,6 +157,8 @@ func applyEnv(cfg *Config) {
setInt(&cfg.MySQL.MaxOpenConns, "IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS") setInt(&cfg.MySQL.MaxOpenConns, "IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS")
setInt(&cfg.MySQL.MaxIdleConns, "IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS") setInt(&cfg.MySQL.MaxIdleConns, "IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS")
setString(&cfg.CoreMySQL.DSN, "IM_WORKBENCH_CORE_MYSQL_DSN") 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) == "" { if strings.TrimSpace(cfg.CoreMySQL.DSN) == "" {
cfg.CoreMySQL.DSN = cfg.MySQL.DSN cfg.CoreMySQL.DSN = cfg.MySQL.DSN
} }
+5 -1
View File
@@ -396,7 +396,11 @@ func (s *Store) SaveCompletedFileUpload(ctx context.Context, file CompletedFileU
if now.IsZero() { if now.IsZero() {
now = time.Now().UTC().Round(time.Millisecond) 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) (tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location)
VALUES(?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?)`,
s.tenant(), file.ID, now, now, formatInt64(file.UserID), 1, file.MIMEType, file.Size, file.ETag, file.Location) s.tenant(), file.ID, now, now, formatInt64(file.UserID), 1, file.MIMEType, file.Size, file.ETag, file.Location)
+4 -1
View File
@@ -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) { 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 var count int
query := fmt.Sprintf(`SELECT COUNT(*) FROM %s 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())) 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) { if isMissingTable(err) {
return 0, nil return 0, nil
} }
+152
View File
@@ -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"