package systemconfig import ( "context" "encoding/json" "errors" "strings" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB } 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: "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: "PaddleOCR-VL-1.6", Description: "PaddleOCR 识别模型"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, } var adminVisibleConfigKeys = []string{ "chat.auto_welcome_message", "chat.default_support_admin_id", "chat.listing_group_welcome", "chat.renter_retention_days_after_order_end", "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", "order.agreements", "order.pending_payment_timeout_minutes", "order.return_overdue_grace_minutes", "profile.post_rental_notice", } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } 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) { 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 := 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 } 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 (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 } if isLegacyPublishOptionsValue(row.Value) { row.Value = item.Value row.Description = item.Description return tx.Save(&row).Error } var options PublishOptionsDTO if err := json.Unmarshal([]byte(row.Value), &options); err != nil { row.Value = item.Value row.Description = item.Description return tx.Save(&row).Error } before := row.Value isOldPublishOptionsSchema := !strings.Contains(before, `"ban_record_options"`) normalizePublishOptions(&options) if isOldPublishOptionsSchema { applyConditionalBanEvidenceDefault(&options) } raw, err := json.Marshal(options) if err != nil { return err } row.Value = string(raw) row.Description = item.Description if row.Value == before { 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 isLegacyPublishOptionsValue(value string) bool { legacyMarkers := []string{ `"kit5"`, "5级全装套", "6级子弹", "6头数量", "6甲数量", "0.26元/发", "0.8元/发", "çº", "å¼", "å…", "å¤", "ä½", "é«", "ï¼", } for _, marker := range legacyMarkers { if strings.Contains(value, marker) { return true } } return false } func isMojibakeText(value string) bool { mojibakeMarkers := []string{"å", "æ", "ç", "è", "é", "ä", "ï¼", "ã€", "â"} for _, marker := range mojibakeMarkers { if strings.Contains(value, marker) { return true } } return false } 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, } }