diff --git a/README.md b/README.md index 6a0f19c..fe7a4c8 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,14 @@ - `/v1/workbench/reports/daily*` - `/v1/workbench/reports/monthly*` - `/v1/workbench/executive-mailbox/*` +- `/internal/admin/workbench/*`,HotGo 后台内部管理接口,使用服务间 HMAC 鉴权 ## 本地运行 ```bash -IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench +IM_WORKBENCH_CONFIG=configs/workbench.local.json \ +IM_WORKBENCH_INTERNAL_SECRET=local-workbench-secret \ +go run ./cmd/workbench ``` 默认监听 `:6061`,默认连接本地 `tinode` 库和本地 Redis。 @@ -43,6 +46,13 @@ X-Request-ID: {uuid} 服务从 Redis 读取 `im:session:token:{sha256(token)}` 校验登录态。 +HotGo 后台访问 `/internal/admin/workbench/*` 时需要配置同一组内部鉴权参数: + +```text +IM_WORKBENCH_INTERNAL_SERVICE_NAME=hotgo-admin +IM_WORKBENCH_INTERNAL_SECRET={shared-secret} +``` + ## Kafka 事件 工作台写操作统一投递到 `im.workbench.event`。本地配置默认关闭 Kafka: diff --git a/configs/workbench.local.json b/configs/workbench.local.json index 3673e5b..3107a2b 100644 --- a/configs/workbench.local.json +++ b/configs/workbench.local.json @@ -22,6 +22,8 @@ "allow_dev_header_auth": true, "required_tenant_code": true, "required_api_key": false, - "skip_session_in_dev": false + "skip_session_in_dev": false, + "internal_service_name": "hotgo-admin", + "internal_secret": "local-workbench-secret" } } diff --git a/configs/workbench.test.json b/configs/workbench.test.json index 08264cf..f38c1e0 100644 --- a/configs/workbench.test.json +++ b/configs/workbench.test.json @@ -26,6 +26,8 @@ "allow_dev_header_auth": false, "required_tenant_code": true, "required_api_key": true, - "skip_session_in_dev": false + "skip_session_in_dev": false, + "internal_service_name": "hotgo-admin", + "internal_secret": "" } } diff --git a/internal/app/app.go b/internal/app/app.go index eef5c99..610ac0e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -53,7 +53,7 @@ func New(cfg *config.Config) (*App, error) { store: st, redis: redisClient, events: publisher, - api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher), + api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher, cfg.Auth), }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index a9a3cdc..924d5ca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,8 @@ type AuthConfig struct { RequiredAPIKey bool `json:"required_api_key"` SkipSessionInDev bool `json:"skip_session_in_dev"` TrustedInternalAddr string `json:"trusted_internal_addr"` + InternalServiceName string `json:"internal_service_name"` + InternalSecret string `json:"internal_secret"` } func Load() (*Config, error) { @@ -109,6 +111,8 @@ func applyEnv(cfg *Config) { setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID") setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN") setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR") + setString(&cfg.Auth.InternalServiceName, "IM_WORKBENCH_INTERNAL_SERVICE_NAME") + setString(&cfg.Auth.InternalSecret, "IM_WORKBENCH_INTERNAL_SECRET") if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" { cfg.Kafka.Brokers = splitCSV(brokers) } diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go new file mode 100644 index 0000000..9da5dc6 --- /dev/null +++ b/internal/httpapi/admin.go @@ -0,0 +1,249 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + page := queryInt(r, "page", 1) + perPage := queryInt(r, "per_page", 20) + var enabled *int + if raw := strings.TrimSpace(r.URL.Query().Get("enabled")); raw != "" { + if value, err := strconv.Atoi(raw); err == nil { + enabled = &value + } + } + items, total, err := a.store.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"items": adminWorkbenchModulePayloads(items), "total": total, "page": page, "per_page": perPage}) + case http.MethodPost: + var body adminWorkbenchModuleRequest + if !decodeJSON(w, r, &body) { + return + } + item, err := a.store.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{ + ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route, + SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission, + }) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, adminWorkbenchModulePayload(*item)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + var body struct { + ID int64 `json:"id"` + Code string `json:"code"` + } + if !decodeJSON(w, r, &body) { + return + } + if err := a.store.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"ok": true}) +} + +func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + var body struct { + Items []struct { + Code string `json:"code"` + SortOrder int `json:"sort_order"` + } `json:"items"` + } + if !decodeJSON(w, r, &body) { + return + } + items := make([]store.AdminWorkbenchReorderItem, 0, len(body.Items)) + for _, item := range body.Items { + items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder}) + } + if err := a.store.ReorderAdminWorkbenchModules(r.Context(), items); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"ok": true}) +} + +func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cfg, err := a.store.AdminMailboxConfig(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, adminMailboxConfigPayload(*cfg)) + case http.MethodPost: + var body adminMailboxConfigRequest + if !decodeJSON(w, r, &body) { + return + } + if err := a.store.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{ + Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields, + MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent, + }); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"ok": true}) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cfg, err := a.store.AdminFeedbackConfig(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, adminFeedbackConfigPayload(*cfg)) + case http.MethodPost: + var body adminFeedbackConfigRequest + if !decodeJSON(w, r, &body) { + return + } + if err := a.store.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{ + Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, + AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice, + }); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"ok": true}) + default: + methodNotAllowed(w, r) + } +} + +type adminWorkbenchModuleRequest struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IconFileID string `json:"icon_file_id"` + Route string `json:"route"` + SortOrder int `json:"sort_order"` + UnreadKey string `json:"unread_key"` + Enabled int `json:"enabled"` + Permission string `json:"permission"` +} + +type adminMailboxConfigRequest struct { + Enabled int `json:"enabled"` + AllowAnonymous int `json:"allow_anonymous"` + RequiredFields string `json:"required_fields"` + MaxLength int `json:"max_length"` + AttachmentPolicy string `json:"attachment_policy"` + NoticeTitle string `json:"notice_title"` + NoticeContent string `json:"notice_content"` +} + +type adminFeedbackConfigRequest struct { + Categories string `json:"categories"` + MaxLength int `json:"max_length"` + AttachmentPolicy string `json:"attachment_policy"` + AllowContact int `json:"allow_contact"` + DiagnosticsNotice string `json:"diagnostics_notice"` +} + +func adminWorkbenchModulePayloads(items []store.AdminWorkbenchModule) []map[string]any { + res := make([]map[string]any, 0, len(items)) + for _, item := range items { + res = append(res, adminWorkbenchModulePayload(item)) + } + return res +} + +func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any { + return map[string]any{ + "id": item.ID, + "code": item.Code, + "name": item.Name, + "iconFileId": item.IconFileID, + "route": item.Route, + "sortOrder": item.SortOrder, + "unreadKey": item.UnreadKey, + "enabled": item.Enabled, + "permission": item.Permission, + "createdAt": formatAdminTime(item.CreatedAt), + "updatedAt": formatAdminTime(item.UpdatedAt), + } +} + +func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any { + return map[string]any{ + "id": cfg.ID, + "enabled": cfg.Enabled, + "allowAnonymous": cfg.AllowAnonymous, + "requiredFields": cfg.RequiredFields, + "maxLength": cfg.MaxLength, + "attachmentPolicy": cfg.AttachmentPolicy, + "noticeTitle": cfg.NoticeTitle, + "noticeContent": cfg.NoticeContent, + "noticeUpdatedAt": formatAdminTime(cfg.NoticeUpdatedAt), + "updatedAt": formatAdminTime(cfg.UpdatedAt), + } +} + +func adminFeedbackConfigPayload(cfg store.AdminFeedbackConfig) map[string]any { + return map[string]any{ + "id": cfg.ID, + "categories": cfg.Categories, + "maxLength": cfg.MaxLength, + "attachmentPolicy": cfg.AttachmentPolicy, + "allowContact": cfg.AllowContact, + "diagnosticsNotice": cfg.DiagnosticsNotice, + "updatedAt": formatAdminTime(cfg.UpdatedAt), + } +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool { + if err := json.NewDecoder(r.Body).Decode(out); err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil) + return false + } + return true +} + +func queryInt(r *http.Request, key string, fallback int) int { + value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key))) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func formatAdminTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339Nano) +} diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 6c4b8eb..34626c3 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -13,14 +13,16 @@ import ( "time" "git.118im.com/im_team/im_workbench/internal/auth" + "git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/im_workbench/internal/events" "git.118im.com/im_team/im_workbench/internal/store" ) type API struct { - store *store.Store - auth *auth.Authenticator - events events.Publisher + store *store.Store + auth *auth.Authenticator + events events.Publisher + authCfg config.AuthConfig } const workbenchEventPublishTimeout = 15 * time.Second @@ -30,11 +32,11 @@ const ( maxMailboxContentLength = 5000 ) -func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher) *API { +func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher, authCfg config.AuthConfig) *API { if publisher == nil { publisher = events.NoopPublisher{} } - return &API{store: st, auth: authenticator, events: publisher} + return &API{store: st, auth: authenticator, events: publisher, authCfg: authCfg} } func (a *API) Register(mux *http.ServeMux) { @@ -59,6 +61,11 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/workbench/reports/monthly/", a.withAuth(a.monthlyReportResource)) mux.HandleFunc("/v1/workbench/executive-mailbox/config", a.withAuth(a.mailboxConfig)) mux.HandleFunc("/v1/workbench/executive-mailbox/submissions", a.withAuth(a.mailboxSubmissions)) + mux.HandleFunc("/internal/admin/workbench/apps", a.withInternalAdminAuth(a.adminWorkbenchApps)) + mux.HandleFunc("/internal/admin/workbench/apps/disable", a.withInternalAdminAuth(a.adminWorkbenchAppDisable)) + mux.HandleFunc("/internal/admin/workbench/apps/reorder", a.withInternalAdminAuth(a.adminWorkbenchAppsReorder)) + mux.HandleFunc("/internal/admin/workbench/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig)) + mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig)) } func (a *API) Healthz(w http.ResponseWriter, r *http.Request) { diff --git a/internal/httpapi/internal_auth.go b/internal/httpapi/internal_auth.go new file mode 100644 index 0000000..3ce39f7 --- /dev/null +++ b/internal/httpapi/internal_auth.go @@ -0,0 +1,72 @@ +package httpapi + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const internalSignatureWindow = 5 * time.Minute + +func (a *API) withInternalAdminAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil) + return + } + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(body)) + if err := a.verifyInternalAdminSignature(r, body); err != nil { + writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil) + return + } + next(w, r) + } +} + +func (a *API) verifyInternalAdminSignature(r *http.Request, body []byte) error { + secret := strings.TrimSpace(a.authCfg.InternalSecret) + if secret == "" { + return ErrForbidden + } + serviceName := strings.TrimSpace(r.Header.Get("X-Service-Name")) + if expected := strings.TrimSpace(a.authCfg.InternalServiceName); expected != "" && serviceName != expected { + return ErrForbidden + } + timestampRaw := strings.TrimSpace(r.Header.Get("X-Timestamp")) + nonce := strings.TrimSpace(r.Header.Get("X-Nonce")) + signature := strings.TrimSpace(r.Header.Get("X-Signature")) + if timestampRaw == "" || nonce == "" || signature == "" { + return ErrForbidden + } + ts, err := strconv.ParseInt(timestampRaw, 10, 64) + if err != nil { + return ErrForbidden + } + when := time.Unix(ts, 0) + if time.Since(when) > internalSignatureWindow || time.Until(when) > internalSignatureWindow { + return ErrForbidden + } + bodyHash := sha256.Sum256(body) + signingText := strings.Join([]string{ + r.Method, + r.URL.RequestURI(), + hex.EncodeToString(bodyHash[:]), + timestampRaw, + nonce, + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(signingText)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(signature)) { + return ErrForbidden + } + return nil +} diff --git a/internal/store/admin.go b/internal/store/admin.go new file mode 100644 index 0000000..7463898 --- /dev/null +++ b/internal/store/admin.go @@ -0,0 +1,226 @@ +package store + +import ( + "context" + "database/sql" + "strings" + "time" +) + +type AdminWorkbenchModule struct { + ID int64 `db:"id"` + Code string `db:"code"` + Name string `db:"name"` + IconFileID string `db:"icon_file_id"` + Route string `db:"route"` + SortOrder int `db:"sort_order"` + UnreadKey string `db:"unread_key"` + Enabled int `db:"enabled"` + Permission string `db:"permission"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type AdminWorkbenchModuleInput struct { + ID int64 + Code string + Name string + IconFileID string + Route string + SortOrder int + UnreadKey string + Enabled int + Permission string +} + +type AdminWorkbenchReorderItem struct { + Code string + SortOrder int +} + +type AdminMailboxConfig struct { + ID int64 `db:"id"` + Enabled int `db:"enabled"` + AllowAnonymous int `db:"allow_anonymous"` + RequiredFields string `db:"required_fields"` + MaxLength int `db:"max_length"` + AttachmentPolicy string `db:"attachment_policy"` + NoticeTitle string `db:"notice_title"` + NoticeContent string `db:"notice_content"` + NoticeUpdatedAt time.Time `db:"notice_updated_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type AdminFeedbackConfig struct { + ID int64 `db:"id"` + Categories string `db:"categories"` + MaxLength int `db:"max_length"` + AttachmentPolicy string `db:"attachment_policy"` + AllowContact int `db:"allow_contact"` + DiagnosticsNotice string `db:"diagnostics_notice"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (s *Store) AdminWorkbenchModules(ctx context.Context, page, perPage int, code, name string, enabled *int) ([]AdminWorkbenchModule, int, error) { + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + where := []string{"1=1"} + args := []any{} + if code = strings.TrimSpace(code); code != "" { + where = append(where, "code LIKE ?") + args = append(args, "%"+code+"%") + } + if name = strings.TrimSpace(name); name != "" { + where = append(where, "name LIKE ?") + args = append(args, "%"+name+"%") + } + if enabled != nil { + where = append(where, "enabled=?") + args = append(args, *enabled) + } + whereSQL := strings.Join(where, " AND ") + var total int + if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_workbench_apps WHERE "+whereSQL, args...); err != nil { + return nil, 0, err + } + queryArgs := append([]any{}, args...) + queryArgs = append(queryArgs, (page-1)*perPage, perPage) + var items []AdminWorkbenchModule + err := s.db.SelectContext(ctx, &items, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at + FROM im_workbench_apps WHERE `+whereSQL+` ORDER BY sort_order ASC,id ASC LIMIT ?,?`, queryArgs...) + return items, total, err +} + +func (s *Store) UpsertAdminWorkbenchModule(ctx context.Context, in AdminWorkbenchModuleInput) (*AdminWorkbenchModule, error) { + now := time.Now().UTC().Round(time.Millisecond) + in.Code = strings.TrimSpace(in.Code) + in.Name = strings.TrimSpace(in.Name) + in.IconFileID = strings.TrimSpace(in.IconFileID) + in.Route = strings.TrimSpace(in.Route) + in.UnreadKey = strings.TrimSpace(in.UnreadKey) + in.Permission = strings.TrimSpace(in.Permission) + if in.Permission == "" { + in.Permission = "enabled" + } + if in.ID > 0 { + res, err := s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET code=?,name=?,icon_file_id=?,route=?,sort_order=?,unread_key=?,enabled=?,permission=?,updated_at=? WHERE id=?`, + in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, in.ID) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrNotFound + } + return s.adminWorkbenchModuleByID(ctx, in.ID) + } + res, err := s.db.ExecContext(ctx, `INSERT INTO im_workbench_apps(code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, now) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return s.adminWorkbenchModuleByID(ctx, id) +} + +func (s *Store) DisableAdminWorkbenchModule(ctx context.Context, id int64, code string) error { + now := time.Now().UTC().Round(time.Millisecond) + var res sql.Result + var err error + if id > 0 { + res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE id=?`, now, id) + } else { + res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE code=?`, now, strings.TrimSpace(code)) + } + if err != nil { + return err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) ReorderAdminWorkbenchModules(ctx context.Context, items []AdminWorkbenchReorderItem) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + now := time.Now().UTC().Round(time.Millisecond) + for _, item := range items { + if _, err = tx.ExecContext(ctx, `UPDATE im_workbench_apps SET sort_order=?,updated_at=? WHERE code=?`, item.SortOrder, now, strings.TrimSpace(item.Code)); err != nil { + return err + } + } + return tx.Commit() +} + +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 id=1`); 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 nil, err + } + return &cfg, nil +} + +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(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at) + VALUES(1,?,?,?,?,?,?,?,?,?,?) + 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)`, + 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 +} + +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 id=1`); 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 nil, err + } + return &cfg, nil +} + +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(id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at) + VALUES(1,?,?,?,?,?,?,?) + 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)`, + 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 +} + +func (s *Store) adminWorkbenchModuleByID(ctx context.Context, id int64) (*AdminWorkbenchModule, error) { + var item AdminWorkbenchModule + if err := s.db.GetContext(ctx, &item, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at FROM im_workbench_apps WHERE id=?`, id); err != nil { + if errorsIsNoRows(err) { + return nil, ErrNotFound + } + return nil, err + } + return &item, nil +} + +func errorsIsNoRows(err error) bool { + return err == sql.ErrNoRows +} + +func defaultString(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +}