Files
hfb_sys/backend/internal/modules/systemconfig/repository.go
T

361 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package systemconfig
import (
"context"
"encoding/json"
"errors"
"strings"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"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: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则(积分门槛、租金折扣、等级免押额度)"},
{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: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"},
{Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"},
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
{Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"},
{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",
"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",
"order.agreements",
"order.pending_payment_timeout_minutes",
"order.return_overdue_grace_minutes",
"profile.post_rental_notice",
"renter.growth_level_rules",
}
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 := 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
}
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,
}
}