diff --git a/README.md b/README.md index 5f39edf..3f7cba6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ IM_WORKBENCH_INTERNAL_SECRET=local-workbench-secret \ go run ./cmd/workbench ``` -默认监听 `:6061`,默认连接本地 `tinode` 库和本地 Redis。 +默认监听 `:6061`,默认连接本地 `meeting_im` 库和本地 Redis。 开发联调可在 `configs/workbench.local.json` 打开 `allow_dev_header_auth`,请求头带: diff --git a/configs/workbench.local.json b/configs/workbench.local.json index aaa1443..73a94b1 100644 --- a/configs/workbench.local.json +++ b/configs/workbench.local.json @@ -1,7 +1,7 @@ { "listen": ":6061", "mysql": { - "dsn": "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&charset=utf8mb4,utf8&loc=Local" + "dsn": "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local" }, "redis": { "addr": "127.0.0.1:6379", diff --git a/configs/workbench.test.json b/configs/workbench.test.json index d50ba12..21171c7 100644 --- a/configs/workbench.test.json +++ b/configs/workbench.test.json @@ -1,7 +1,7 @@ { "listen": ":6061", "mysql": { - "dsn": "root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/tinode?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": { "addr": "redis.118im.com:30637", diff --git a/internal/config/config.go b/internal/config/config.go index 95c29a0..5d5cdef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" "time" + + mysqlDriver "github.com/go-sql-driver/mysql" ) type Config struct { @@ -88,7 +90,7 @@ func defaultConfig() *Config { return &Config{ Listen: ":6061", MySQL: MySQLConfig{ - DSN: "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&charset=utf8mb4,utf8&loc=Local", + DSN: "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local", }, Redis: RedisConfig{ Addr: "127.0.0.1:6379", @@ -186,6 +188,14 @@ func (c *Config) validate() error { if strings.TrimSpace(c.MySQL.DSN) == "" { return errors.New("mysql dsn is required") } + mysqlDSN := strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://") + parsedDSN, err := mysqlDriver.ParseDSN(mysqlDSN) + if err != nil { + return fmt.Errorf("mysql dsn is invalid: %w", err) + } + if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") { + return errors.New("mysql database tinode is deprecated; use meeting_im") + } if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev { return errors.New("redis addr is required") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..25f1209 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,26 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidateRejectsDeprecatedTinodeDatabase(t *testing.T) { + cfg := defaultConfig() + cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true" + + err := cfg.validate() + if err == nil || !strings.Contains(err.Error(), "tinode is deprecated") { + t.Fatalf("validate() error = %v, want deprecated database error", err) + } +} + +func TestDefaultConfigUsesMeetingIMDatabase(t *testing.T) { + cfg := defaultConfig() + if err := cfg.validate(); err != nil { + t.Fatalf("default config validation failed: %v", err) + } + if !strings.Contains(cfg.MySQL.DSN, "/meeting_im?") { + t.Fatalf("default mysql dsn = %q, want meeting_im database", cfg.MySQL.DSN) + } +} diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go index 6d12508..1995e45 100644 --- a/internal/httpapi/admin.go +++ b/internal/httpapi/admin.go @@ -10,6 +10,42 @@ import ( "git.118im.com/im_team/im_workbench/internal/store" ) +func (a *API) adminInitializeTenant(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + var body struct { + TenantID int64 `json:"tenant_id"` + TenantCode string `json:"tenant_code"` + } + if !decodeJSON(w, r, &body) { + return + } + if body.TenantID <= 0 { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_id"}) + return + } + st, err := a.store.ForTenant(r.Context(), body.TenantID, strings.TrimSpace(body.TenantCode)) + if err != nil { + writeStoreError(w, r, err) + return + } + result, err := st.InitializeTenantDefaults(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{ + "tenant_id": body.TenantID, + "workbench_apps": result.WorkbenchApps, + "attendance_rules": result.AttendanceRules, + "attendance_shifts": result.AttendanceShifts, + "mailbox_configs": result.MailboxConfigs, + "feedback_configs": result.FeedbackConfigs, + }) +} + func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) { st, ok := a.internalAdminStore(w, r) if !ok { diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index e2bd9fe..aeb9b1b 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -68,6 +68,7 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/internal/admin/workbench/organization/members", a.withInternalAdminAuth(a.adminOrganizationMembers)) mux.HandleFunc("/internal/admin/workbench/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig)) mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig)) + mux.HandleFunc("/internal/admin/workbench/tenants/initialize", a.withInternalAdminAuth(a.adminInitializeTenant)) } func (a *API) Healthz(w http.ResponseWriter, r *http.Request) { diff --git a/internal/store/admin.go b/internal/store/admin.go index 843677c..2193c45 100644 --- a/internal/store/admin.go +++ b/internal/store/admin.go @@ -231,10 +231,10 @@ func (s *Store) AdminOrganizationMembers(ctx context.Context, page, perPage int, func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, error) { var cfg AdminMailboxConfig - if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE tenant_id=? AND id=1`, s.tenant()); err != nil { + if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil { if errorsIsNoRows(err) { now := time.Now().UTC().Round(time.Millisecond) - return &AdminMailboxConfig{ID: 1, Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求,匿名提交将按企业政策保护展示身份。", NoticeUpdatedAt: now, UpdatedAt: now}, nil + return &AdminMailboxConfig{Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", NoticeUpdatedAt: now, UpdatedAt: now}, nil } return nil, err } @@ -243,8 +243,8 @@ func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, er func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxConfig) error { now := time.Now().UTC().Round(time.Millisecond) - _, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(tenant_id,id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at) - VALUES(?,1,?,?,?,?,?,?,?,?,?,?) + _, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE enabled=VALUES(enabled),allow_anonymous=VALUES(allow_anonymous),required_fields=VALUES(required_fields),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),notice_title=VALUES(notice_title),notice_content=VALUES(notice_content),notice_updated_at=VALUES(notice_updated_at),updated_at=VALUES(updated_at)`, s.tenant(), cfg.Enabled, cfg.AllowAnonymous, defaultString(cfg.RequiredFields, `["subject","content"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"executive_mailbox"}`), cfg.NoticeTitle, cfg.NoticeContent, now, now, now) return err @@ -252,9 +252,9 @@ func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxCo func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig, error) { var cfg AdminFeedbackConfig - if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE tenant_id=? AND id=1`, s.tenant()); err != nil { + if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil { if errorsIsNoRows(err) { - return &AdminFeedbackConfig{ID: 1, Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 1000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, DiagnosticsNotice: "仅在你同意时上传脱敏诊断信息。", UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil + return &AdminFeedbackConfig{Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 2000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil } return nil, err } @@ -263,8 +263,8 @@ func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig, func (s *Store) UpdateAdminFeedbackConfig(ctx context.Context, cfg AdminFeedbackConfig) error { now := time.Now().UTC().Round(time.Millisecond) - _, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(tenant_id,id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at) - VALUES(?,1,?,?,?,?,?,?,?) + _, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE categories=VALUES(categories),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),allow_contact=VALUES(allow_contact),diagnostics_notice=VALUES(diagnostics_notice),updated_at=VALUES(updated_at)`, s.tenant(), defaultString(cfg.Categories, `["bug","suggestion","complaint","other"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"feedback"}`), cfg.AllowContact, cfg.DiagnosticsNotice, now, now) return err diff --git a/internal/store/mysql.go b/internal/store/mysql.go index ba7aa15..f9c873d 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -17,7 +17,6 @@ import ( "time" "git.118im.com/im_team/im_workbench/internal/config" - mysqlDriver "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" "golang.org/x/crypto/xtea" ) @@ -106,14 +105,6 @@ func (s *Store) Close() error { return s.db.Close() } -type tenantDBConfig struct { - ID int64 `db:"id"` - Code string `db:"code"` - DBName string `db:"db_name"` - DBDSN string `db:"db_dsn"` - State int `db:"state"` -} - // ForTenant returns a tenant-scoped Store over the configured shared database. func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string) (*Store, error) { if s == nil { @@ -139,46 +130,6 @@ func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string return st, nil } -func (s *Store) tenantDBConfig(ctx context.Context, tenantID int64, tenantCode string) (*tenantDBConfig, error) { - var cfg tenantDBConfig - if tenantID > 0 { - err := s.db.GetContext(ctx, &cfg, `SELECT id,code,IFNULL(db_name,'') AS db_name,IFNULL(db_dsn,'') AS db_dsn,state - FROM hotgo.im_tenant WHERE id=? LIMIT 1`, tenantID) - if err == nil { - return &cfg, nil - } - if !errors.Is(err, sql.ErrNoRows) || strings.TrimSpace(tenantCode) == "" { - return nil, err - } - } - err := s.db.GetContext(ctx, &cfg, `SELECT id,code,IFNULL(db_name,'') AS db_name,IFNULL(db_dsn,'') AS db_dsn,state - FROM hotgo.im_tenant WHERE code=? LIMIT 1`, strings.TrimSpace(tenantCode)) - if err != nil { - return nil, err - } - return &cfg, nil -} - -func tenantDSN(baseDSN, dbName, dbDSN string) (string, error) { - dbDSN = strings.TrimSpace(dbDSN) - if strings.HasPrefix(dbDSN, "mysql:") { - dbDSN = strings.TrimPrefix(dbDSN, "mysql:") - } - if dbDSN != "" { - return dbDSN, nil - } - dbName = strings.TrimSpace(dbName) - if dbName == "" { - return baseDSN, nil - } - cfg, err := mysqlDriver.ParseDSN(baseDSN) - if err != nil { - return "", err - } - cfg.DBName = dbName - return cfg.FormatDSN(), nil -} - func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]WorkbenchModule, int, error) { var modules []WorkbenchModule err := s.db.SelectContext(ctx, &modules, `SELECT code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,0 AS unread_count,enabled,permission @@ -1087,7 +1038,7 @@ func (s *Store) MonthlyReportStats(ctx context.Context, month, departmentID stri func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) { var cfg MailboxConfig - if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE tenant_id=? AND id=1`, s.tenant()); err != nil { + if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil { if errors.Is(err, sql.ErrNoRows) { return &MailboxConfig{Enabled: true, AllowAnonymous: true, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", Version: 1}, nil } diff --git a/internal/store/mysql_test.go b/internal/store/mysql_test.go deleted file mode 100644 index 8190c55..0000000 --- a/internal/store/mysql_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package store - -import ( - "strings" - "testing" -) - -func TestTenantDSNUsesConfiguredDSN(t *testing.T) { - got, err := tenantDSN("root:pass@tcp(127.0.0.1:3306)/tinode?parseTime=true", "", "mysql:user:secret@tcp(db:3306)/meeting_im?parseTime=true") - if err != nil { - t.Fatalf("tenantDSN returned error: %v", err) - } - want := "user:secret@tcp(db:3306)/meeting_im?parseTime=true" - if got != want { - t.Fatalf("tenantDSN = %q, want %q", got, want) - } -} - -func TestTenantDSNDerivesDBName(t *testing.T) { - got, err := tenantDSN("root:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4&parseTime=true&loc=Local", "meeting_im", "") - if err != nil { - t.Fatalf("tenantDSN returned error: %v", err) - } - if got == "" || got == "root:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4&parseTime=true&loc=Local" { - t.Fatalf("tenantDSN did not derive a tenant database DSN: %q", got) - } - if parsed := "meeting_im"; !strings.Contains(got, "/"+parsed+"?") { - t.Fatalf("tenantDSN = %q, want database %q", got, parsed) - } -} diff --git a/internal/store/tenant_init.go b/internal/store/tenant_init.go new file mode 100644 index 0000000..0f9cf27 --- /dev/null +++ b/internal/store/tenant_init.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "time" +) + +// TenantInitializationResult reports how many missing defaults were created. +// Existing tenant customizations are deliberately left untouched. +type TenantInitializationResult struct { + WorkbenchApps int64 + AttendanceRules int64 + AttendanceShifts int64 + MailboxConfigs int64 + FeedbackConfigs int64 +} + +// InitializeTenantDefaults creates the minimum workbench data required by a tenant. +// Tenant-scoped unique indexes make this operation safe to retry. +func (s *Store) InitializeTenantDefaults(ctx context.Context) (*TenantInitializationResult, error) { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + now := time.Now().UTC().Round(time.Millisecond) + result := &TenantInitializationResult{} + + apps := []struct { + code, name, route string + sort int + }{ + {code: "attendance", name: "打卡", route: "/attendance", sort: 10}, + {code: "monthly_reports", name: "月报", route: "/reports/monthly", sort: 20}, + {code: "daily_reports", name: "日报", route: "/reports/daily", sort: 30}, + {code: "organization", name: "部门框架", route: "/organization", sort: 40}, + {code: "executive_mailbox", name: "举报信箱", route: "/executive-mailbox", sort: 50}, + {code: "meetings", name: "会议通知", route: "/meetings", sort: 60}, + {code: "meeting_notes", name: "会议笔记", route: "/meeting-notes", sort: 70}, + } + for _, app := range apps { + res, execErr := tx.ExecContext(ctx, `INSERT INTO im_workbench_apps + (tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`, + s.tenant(), app.code, app.name, "", app.route, app.sort, "", 1, "enabled", now, now) + if execErr != nil { + return nil, execErr + } + rows, _ := res.RowsAffected() + if rows == 1 { + result.WorkbenchApps++ + } + } + + res, err := tx.ExecContext(ctx, `INSERT INTO im_attendance_rules + (tenant_id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,effective_from,effective_to,state,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,CURDATE(),NULL,'active',?,?) + ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`, + s.tenant(), "att_rule_default", "默认考勤规则", "1,2,3,4,5", 0, 0, 1, 1, now, now) + if err != nil { + return nil, err + } + result.AttendanceRules, _ = res.RowsAffected() + + res, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_shifts + (tenant_id,shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,state,sort_order,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`, + s.tenant(), "shift_default", "att_rule_default", "默认班次", "09:00:00", "18:00:00", "08:00:00", "19:00:00", "active", 10, now, now) + if err != nil { + return nil, err + } + result.AttendanceShifts, _ = res.RowsAffected() + + res, err = tx.ExecContext(ctx, `INSERT INTO im_mailbox_config + (tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at) + VALUES(?,1,1,?,5000,?,'总经理信箱','请如实填写诉求。',?,?,?) + ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`, + s.tenant(), `["subject","content"]`, `{"max_files":5,"purpose":"executive_mailbox"}`, now, now, now) + if err != nil { + return nil, err + } + result.MailboxConfigs, _ = res.RowsAffected() + + res, err = tx.ExecContext(ctx, `INSERT INTO im_feedback_config + (tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at) + VALUES(?,?,2000,?,1,'',?,?) + ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`, + s.tenant(), `["bug","suggestion","complaint","other"]`, `{"max_files":5,"purpose":"feedback"}`, now, now) + if err != nil { + return nil, err + } + result.FeedbackConfigs, _ = res.RowsAffected() + + if err = tx.Commit(); err != nil { + return nil, err + } + return result, nil +}