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

504 lines
16 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"
"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,
}
}