Files
hfb_sys/backend/internal/modules/systemconfig/repository.go
T
ymlandClaude Opus 4.7 5d5bd5af77 feat: 添加租后须知功能并优化发布默认设置
新增功能:
- 添加租后须知查看功能(PC端+移动端)
- 管理后台支持配置租后须知内容
- 默认提供完整的租后须知模板

功能实现:
- 后端:新增 PostRentalNotice API 接口和配置管理
- PC端:在用户下拉菜单添加租后须知入口,独立页面展示
- 移动端:在个人中心实名认证下方添加入口,弹窗展示
- 管理端:新增租后须知配置弹窗,支持编辑和恢复默认

优化改进:
- 修复发布页面默认选中"参考比例"而非"自定比例"
- 移除错误的迁移测试脚本(与 MySQL 8.0 不兼容)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 09:55:11 +08:00

275 lines
7.9 KiB
Go

package systemconfig
import (
"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: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
{Key: "chat.default_support_admin_id", Value: "1", Description: "订单群聊回退客服 ID(仅当无可用客服时使用)"},
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
{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",
"handoff.owner_return_confirm_timeout_minutes",
"handoff.owner_submit_timeout_minutes",
"handoff.renter_confirm_timeout_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() ([]ConfigDTO, error) {
if err := r.ensureDefaults(); err != nil {
return nil, err
}
var rows []model.SystemConfig
if err := r.db.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(key string) (string, error) {
if err := r.ensureDefaults(); err != nil {
return "", err
}
var row model.SystemConfig
if err := r.db.Where("`key` = ?", key).First(&row).Error; err != nil {
return "", err
}
return row.Value, nil
}
func (r *Repository) Update(actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
var row model.SystemConfig
err := r.db.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() error {
return r.db.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 := updateDefaultDescription(tx, item); err != nil {
return err
}
}
if item.Key == publishOptionsConfigKey {
if err := updateLegacyPublishOptions(tx, item); err != nil {
return err
}
}
}
return nil
})
}
func updateDefaultDescription(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 row.Description == item.Description {
return nil
}
row.Description = item.Description
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 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,
}
}