package systemconfig import ( "context" "encoding/json" "errors" "strings" "sync" "unicode/utf8" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/rentergrowth" smsnode "hfb_sys/backend/internal/modules/sms" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB initMu sync.RWMutex initialized bool } type AuditMeta = auditlog.Meta type defaultConfig struct { Key string Value string Description string } var defaultConfigs = []defaultConfig{ {Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"}, {Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"}, {Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认结账超时分钟数"}, {Key: "order.pending_payment_timeout_minutes", Value: "15", Description: "订单待支付超时取消分钟数"}, {Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"}, {Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"}, {Key: "listing.publish_cooldown_minutes", Value: "5", Description: "同一用户两次新增发布账号的最小间隔分钟数(0 表示关闭)"}, {Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"}, {Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"}, {Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"}, {Key: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则(积分门槛、租金折扣、等级免押额度)"}, {Key: smsnode.ConfigKey, Value: smsnode.DefaultConfigValue(), Description: "节点短信模板配置(总开关、各业务节点模板 CODE)"}, {Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"}, {Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"}, {Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"}, {Key: "chat.renter_retention_days_after_order_end", Value: "5", Description: "订单结束后租客保留在发布群的天数(3-7)"}, {Key: "integration.paddle_ocr_token", Value: "", Description: "PaddleOCR API Token,用于二维码群名自动识别"}, {Key: "integration.paddle_ocr_job_url", Value: "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", Description: "PaddleOCR 异步任务接口地址"}, {Key: "integration.paddle_ocr_model", Value: "PP-OCRv6", Description: "PaddleOCR 识别模型(默认 PP-OCRv6,支持 PaddleOCR-VL-1.6 等)"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"}, {Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"}, {Key: homeEntriesEnabledConfigKey, Value: defaultHomeEntriesEnabledConfigValue(), Description: "首页 AW炫彩回收/合作与反馈入口总开关(关闭后 PC/移动端均隐藏)"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, {Key: DistributionSettlementHoldDaysKey, Value: defaultDistributionSettlementHoldDaysValue(), Description: "新建分销站点默认结算冷却天数(0=立即释放,最大 365)"}, {Key: "backup.schedule_enabled", Value: "true", Description: "在线备份定时任务是否启用"}, {Key: "backup.full_hour", Value: "4", Description: "每日全量备份小时(Asia/Shanghai)"}, {Key: "backup.full_minute", Value: "30", Description: "每日全量备份分钟(Asia/Shanghai)"}, {Key: "backup.binlog_interval_minutes", Value: "1", Description: "binlog 归档间隔分钟(1-5)"}, } var adminVisibleConfigKeys = []string{ "chat.auto_welcome_message", "chat.default_support_admin_id", "chat.listing_group_welcome", "chat.renter_retention_days_after_order_end", "distribution.default_settlement_hold_days", "handoff.owner_return_confirm_timeout_minutes", "handoff.owner_submit_timeout_minutes", "handoff.renter_confirm_timeout_minutes", "integration.paddle_ocr_job_url", "integration.paddle_ocr_model", "integration.paddle_ocr_token", "listing.publish_agreements", "listing.publish_cooldown_minutes", "listing.publish_options", "listing.review_required", "listing.sale_price_config", "mobile.home_announcements", "mobile.home_banners", "mobile.aw_recycle", "mobile.cooperation_feedback", "mobile.home_entries_enabled", "order.agreements", "order.pending_payment_timeout_minutes", "order.return_overdue_grace_minutes", "profile.post_rental_notice", "renter.growth_level_rules", "sms.business_templates", } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } // EnsureDefaults 仅在服务启动或首次访问时初始化默认系统配置。 // 使用读写锁保证进程生命周期内只真正落库初始化一次,避免每个读请求重复开事务写锁争抢。 func (r *Repository) EnsureDefaults(ctx context.Context) error { r.initMu.RLock() if r.initialized { r.initMu.RUnlock() return nil } r.initMu.RUnlock() r.initMu.Lock() defer r.initMu.Unlock() if r.initialized { return nil } if err := r.ensureDefaults(ctx); err != nil { return err } r.initialized = true return nil } func (r *Repository) List(ctx context.Context) ([]ConfigDTO, error) { if err := r.EnsureDefaults(ctx); err != nil { return nil, err } var rows []model.SystemConfig if err := r.db.WithContext(ctx).Where("`key` IN ?", adminVisibleConfigKeys).Order("`key` ASC").Find(&rows).Error; err != nil { return nil, err } items := make([]ConfigDTO, 0, len(rows)) for _, row := range rows { items = append(items, toDTO(row)) } return items, nil } func (r *Repository) FindValue(ctx context.Context, key string) (string, error) { if err := r.EnsureDefaults(ctx); err != nil { return "", err } var row model.SystemConfig if err := r.db.WithContext(ctx).Where("`key` = ?", key).First(&row).Error; err != nil { return "", err } return row.Value, nil } func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) { if err := validateConfigValue(key, req.Value); err != nil { return nil, err } var row model.SystemConfig err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("`key` = ?", key).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { row = model.SystemConfig{ Key: key, Value: req.Value, Description: req.Description, UpdatedBy: &actorID, } if err := tx.Create(&row).Error; err != nil { return err } } else { return err } } else { before := row.Value row.Value = req.Value if req.Description != "" { row.Description = req.Description } row.UpdatedBy = &actorID if err := tx.Save(&row).Error; err != nil { return err } if err := syncRenterGrowthLevels(tx, key, req.Value); err != nil { return err } if err := appendAuditLog(tx, actorID, "system_config.update", row.ID, meta, map[string]any{ "key": row.Key, "before": before, "after": row.Value, }); err != nil { return err } return nil } if err := syncRenterGrowthLevels(tx, key, req.Value); err != nil { return err } return appendAuditLog(tx, actorID, "system_config.create", row.ID, meta, map[string]any{ "key": row.Key, "value": row.Value, }) }) if err != nil { return nil, err } dto := toDTO(row) return &dto, nil } func syncRenterGrowthLevels(tx *gorm.DB, key string, value string) error { if key != rentergrowth.ConfigKey { return nil } cfg, err := rentergrowth.ParseConfigValue(value) if err != nil { return err } return rentergrowth.SyncAllUserLevels(tx, cfg) } func (r *Repository) ensureDefaults(ctx context.Context) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, item := range defaultConfigs { row := model.SystemConfig{ Key: item.Key, Value: item.Value, Description: item.Description, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil { return err } if item.Key == "chat.default_support_admin_id" { if err := updateDefaultSupportConfig(tx, item); err != nil { return err } } if item.Key == "chat.listing_group_welcome" { if err := updateLegacyListingGroupWelcome(tx, item); err != nil { return err } } if item.Key == publishOptionsConfigKey { if err := updateLegacyPublishOptions(tx, item); err != nil { return err } } } return nil }) } func updateLegacyListingGroupWelcome(tx *gorm.DB, item defaultConfig) error { var row model.SystemConfig if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil { return err } changed := false if isMojibakeText(row.Value) { row.Value = item.Value changed = true } if row.Description != item.Description { row.Description = item.Description changed = true } if !changed { return nil } return tx.Save(&row).Error } func updateDefaultSupportConfig(tx *gorm.DB, item defaultConfig) error { var row model.SystemConfig if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil { return err } changed := false if strings.TrimSpace(row.Value) == "1" { row.Value = item.Value changed = true } if row.Description != item.Description { row.Description = item.Description changed = true } if !changed { return nil } return tx.Save(&row).Error } func updateLegacyPublishOptions(tx *gorm.DB, item defaultConfig) error { var row model.SystemConfig if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil { return err } normalized, err := normalizePublishOptionsValue(row.Value) if err != nil { normalized = item.Value } changed := row.Value != normalized || row.Description != item.Description row.Value = normalized row.Description = item.Description if !changed { return nil } return tx.Save(&row).Error } func applyConditionalBanEvidenceDefault(options *PublishOptionsDTO) { defaults := DefaultPublishOptions() defaultSlots := make(map[string]PublishScreenshotSlot, len(defaults.ScreenshotSlots)) for _, item := range defaults.ScreenshotSlots { defaultSlots[item.Key] = item } for index, item := range options.ScreenshotSlots { if item.Key != "tencentSecurity" { continue } if next, ok := defaultSlots[item.Key]; ok { options.ScreenshotSlots[index] = next } } } func normalizePublishOptionsValue(value string) (string, error) { value = repairMojibakeJSON(value) var options PublishOptionsDTO if err := json.Unmarshal([]byte(value), &options); err != nil { return "", err } var fields map[string]json.RawMessage if err := json.Unmarshal([]byte(value), &fields); err != nil { return "", err } if options.SchemaVersion < publishOptionsSchemaVersion { if hasLegacyPublishQuantityItem(options.QuantityItems) { options = DefaultPublishOptions() } else { normalizePublishOptions(&options) if _, ok := fields["ban_record_options"]; !ok { applyConditionalBanEvidenceDefault(&options) } } } else { normalizePublishOptions(&options) } options.SchemaVersion = publishOptionsSchemaVersion raw, err := json.Marshal(options) if err != nil { return "", err } return string(raw), nil } func hasLegacyPublishQuantityItem(items []PublishQuantityItem) bool { for _, item := range items { if item.Key == "kit5" { return true } } return false } // repairMojibakeJSON 先按整段 JSON 还原;整段不是纯乱码时,再逐个字符串字段尝试还原。 func repairMojibakeJSON(value string) string { if decoded, ok := decodeCP1252Mojibake(value); ok { return decoded } var root any if err := json.Unmarshal([]byte(value), &root); err != nil { return value } repaired, changed := repairMojibakeValue(root) if !changed { return value } raw, err := json.Marshal(repaired) if err != nil { return value } return string(raw) } func repairMojibakeValue(value any) (any, bool) { switch current := value.(type) { case string: if decoded, ok := decodeCP1252Mojibake(current); ok { return decoded, true } return current, false case []any: changed := false for index := range current { next, updated := repairMojibakeValue(current[index]) if updated { current[index] = next changed = true } } return current, changed case map[string]any: changed := false for key, item := range current { next, updated := repairMojibakeValue(item) if updated { current[key] = next changed = true } } return current, changed default: return value, false } } // decodeCP1252Mojibake 把「UTF-8 字节被当作 CP1252/Latin-1 字符」的文本还原为原始 UTF-8。 // 普通中文包含 CP1252 无法表示的字符,会直接判定失败;纯 ASCII 也不会误判。 func decodeCP1252Mojibake(value string) (string, bool) { if value == "" { return "", false } raw := make([]byte, 0, len(value)) sawNonASCII := false for _, char := range value { next, ok := cp1252Byte(char) if !ok { return "", false } if next >= 0x80 { sawNonASCII = true } raw = append(raw, next) } if !sawNonASCII || !utf8.Valid(raw) { return "", false } decoded := string(raw) if decoded == value { return "", false } // 只接受能还原出汉字的候选,避免把正常的西文重音字符误判为乱码。 if !containsHan(decoded) { return "", false } return decoded, true } func containsHan(value string) bool { for _, char := range value { if char >= 0x4E00 && char <= 0x9FFF { return true } } return false } // cp1252HighRunes 是 CP1252 中 0x80-0x9F 对应的 Unicode 字符; // 其余可映射字符与 Latin-1 一致。 var cp1252HighRunes = [32]rune{ 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, } func cp1252Byte(char rune) (byte, bool) { if char <= 0xFF { return byte(char), true } for offset, candidate := range cp1252HighRunes { if char == candidate { return byte(0x80 + offset), true } } return 0, false } func isMojibakeText(value string) bool { _, ok := decodeCP1252Mojibake(value) return ok } func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: actorID, Action: action, BizType: "system_config", BizID: &bizID, Meta: meta, Detail: detail, }) } func toDTO(row model.SystemConfig) ConfigDTO { return ConfigDTO{ ID: row.ID, Key: row.Key, Value: row.Value, Description: row.Description, UpdatedBy: row.UpdatedBy, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } }