361 lines
12 KiB
Go
361 lines
12 KiB
Go
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,
|
||
}
|
||
}
|