fix: split workbench core mysql access
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+152
@@ -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"
|
||||||
Reference in New Issue
Block a user