chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
@@ -0,0 +1,503 @@
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,
}
}