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,43 @@
package systemconfig
import (
"encoding/json"
"strings"
)
const awRecycleConfigKey = "mobile.aw_recycle"
func defaultAWRecycle() AWRecycleConfig {
return AWRecycleConfig{
Title: "AW炫彩回收",
Subtitle: "点击查看回收说明",
ImageURL: "",
Enabled: true,
}
}
func defaultAWRecycleConfigValue() string {
raw, err := json.Marshal(defaultAWRecycle())
if err != nil {
return `{"title":"AW炫彩回收","subtitle":"点击查看回收说明","image_url":"","enabled":true}`
}
return string(raw)
}
func normalizeAWRecycle(item AWRecycleConfig) AWRecycleConfig {
def := defaultAWRecycle()
title := strings.TrimSpace(item.Title)
if title == "" {
title = def.Title
}
subtitle := strings.TrimSpace(item.Subtitle)
if subtitle == "" {
subtitle = def.Subtitle
}
return AWRecycleConfig{
Title: title,
Subtitle: subtitle,
ImageURL: strings.TrimSpace(item.ImageURL),
Enabled: item.Enabled,
}
}
@@ -0,0 +1,43 @@
package systemconfig
import (
"encoding/json"
"strings"
)
const cooperationFeedbackConfigKey = "mobile.cooperation_feedback"
func defaultCooperationFeedback() CooperationFeedbackConfig {
return CooperationFeedbackConfig{
Title: "合作与反馈",
Subtitle: "点击查看联系方式",
ImageURL: "",
Enabled: true,
}
}
func defaultCooperationFeedbackConfigValue() string {
raw, err := json.Marshal(defaultCooperationFeedback())
if err != nil {
return `{"title":"合作与反馈","subtitle":"点击查看联系方式","image_url":"","enabled":true}`
}
return string(raw)
}
func normalizeCooperationFeedback(item CooperationFeedbackConfig) CooperationFeedbackConfig {
def := defaultCooperationFeedback()
title := strings.TrimSpace(item.Title)
if title == "" {
title = def.Title
}
subtitle := strings.TrimSpace(item.Subtitle)
if subtitle == "" {
subtitle = def.Subtitle
}
return CooperationFeedbackConfig{
Title: title,
Subtitle: subtitle,
ImageURL: strings.TrimSpace(item.ImageURL),
Enabled: item.Enabled,
}
}
@@ -0,0 +1,50 @@
package systemconfig
import (
"strconv"
"strings"
smsnode "hfb_sys/backend/internal/modules/sms"
)
// DistributionSettlementHoldDaysKey 是「新建分销站点默认结算冷却天数」的系统配置键。
const DistributionSettlementHoldDaysKey = "distribution.default_settlement_hold_days"
// DefaultDistributionSettlementHoldDays 是新建分销站点默认结算冷却天数的兜底值。
const DefaultDistributionSettlementHoldDays = 7
func defaultDistributionSettlementHoldDaysValue() string {
return strconv.Itoa(DefaultDistributionSettlementHoldDays)
}
// ParseDistributionSettlementHoldDays 解析结算冷却天数配置,为空、非法或越界时回落到默认 7 天。
func ParseDistributionSettlementHoldDays(raw string) int {
days, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || days < 0 || days > 365 {
return DefaultDistributionSettlementHoldDays
}
return days
}
// validateConfigValue 按配置键分发校验逻辑,未注册校验的键直接放行。
func validateConfigValue(key, value string) error {
switch key {
case DistributionSettlementHoldDaysKey:
return validateDistributionSettlementHoldDays(value)
case smsnode.ConfigKey:
if _, err := smsnode.ParseConfigValue(value); err != nil {
return ErrInvalidConfig
}
return nil
default:
return nil
}
}
func validateDistributionSettlementHoldDays(value string) error {
days, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || days < 0 || days > 365 {
return ErrInvalidConfig
}
return nil
}
@@ -0,0 +1,66 @@
package systemconfig
import "testing"
func TestParseDistributionSettlementHoldDays(t *testing.T) {
cases := []struct {
name string
raw string
want int
}{
{name: "零天立即释放", raw: "0", want: 0},
{name: "默认七天", raw: "7", want: 7},
{name: "上限三百六十五天", raw: "365", want: 365},
{name: "负数回落", raw: "-1", want: DefaultDistributionSettlementHoldDays},
{name: "超过上限回落", raw: "366", want: DefaultDistributionSettlementHoldDays},
{name: "空值回落", raw: " ", want: DefaultDistributionSettlementHoldDays},
{name: "非数字回落", raw: "abc", want: DefaultDistributionSettlementHoldDays},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ParseDistributionSettlementHoldDays(tc.raw); got != tc.want {
t.Fatalf("ParseDistributionSettlementHoldDays(%q) = %d, want %d", tc.raw, got, tc.want)
}
})
}
}
func TestDistributionSettlementHoldDaysConfigRegistered(t *testing.T) {
value := ""
for _, item := range defaultConfigs {
if item.Key == DistributionSettlementHoldDaysKey {
value = item.Value
break
}
}
if value != defaultDistributionSettlementHoldDaysValue() {
t.Fatalf("defaultConfigs 中 %s 的默认值 = %q, want %q", DistributionSettlementHoldDaysKey, value, defaultDistributionSettlementHoldDaysValue())
}
visible := false
for _, key := range adminVisibleConfigKeys {
if key == DistributionSettlementHoldDaysKey {
visible = true
break
}
}
if !visible {
t.Fatalf("adminVisibleConfigKeys 缺少 %s", DistributionSettlementHoldDaysKey)
}
}
func TestValidateConfigValueDistributionSettlementHoldDays(t *testing.T) {
for _, value := range []string{"0", "7", "365"} {
if err := validateConfigValue(DistributionSettlementHoldDaysKey, value); err != nil {
t.Fatalf("validateConfigValue(%q) = %v, want nil", value, err)
}
}
for _, value := range []string{"-1", "366", "", "abc"} {
if err := validateConfigValue(DistributionSettlementHoldDaysKey, value); err != ErrInvalidConfig {
t.Fatalf("validateConfigValue(%q) = %v, want ErrInvalidConfig", value, err)
}
}
// 未注册校验的键直接放行。
if err := validateConfigValue("handoff.owner_submit_timeout_minutes", "abc"); err != nil {
t.Fatalf("无关配置键应放行, got %v", err)
}
}
@@ -0,0 +1,221 @@
package systemconfig
import "time"
type ConfigDTO struct {
ID uint64 `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
Description string `json:"description"`
UpdatedBy *uint64 `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UpdateRequest struct {
Value string `json:"value"`
Description string `json:"description"`
}
// BackupScheduleDTO 仅描述调度时间,不包含备份目录、OSS 地址或任何密钥。
type BackupScheduleDTO struct {
Enabled bool `json:"enabled"`
FullHour int `json:"full_hour"`
FullMinute int `json:"full_minute"`
BinlogIntervalMinutes int `json:"binlog_interval_minutes"`
}
type HomeAnnouncementsDTO struct {
Items []string `json:"items"`
}
type HomeConfigDTO struct {
Announcements []string `json:"announcements"`
Banners []HomeBannerItem `json:"banners"`
PublishOptions PublishOptionsDTO `json:"publish_options"`
AWRecycle AWRecycleConfig `json:"aw_recycle"`
CooperationFeedback CooperationFeedbackConfig `json:"cooperation_feedback"`
// HomeEntriesEnabled 首页业务入口总开关:关闭后 PC/移动端同时隐藏回收与合作入口。
HomeEntriesEnabled bool `json:"home_entries_enabled"`
}
// AWRecycleConfig 首页「AW炫彩回收」入口:点击展示可配置图片。
type AWRecycleConfig struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ImageURL string `json:"image_url"`
Enabled bool `json:"enabled"`
}
// CooperationFeedbackConfig 首页「合作与反馈」入口:点击展示可配置图片。
type CooperationFeedbackConfig struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ImageURL string `json:"image_url"`
Enabled bool `json:"enabled"`
}
type OrderAgreementsDTO struct {
VirtualAssetPurchase AgreementContentDTO `json:"virtual_asset_purchase"`
RenterAgreement AgreementContentDTO `json:"renter_agreement"`
}
type ListingPublishAgreementsDTO struct {
VirtualAssetSale AgreementContentDTO `json:"virtual_asset_sale"`
SellerAgreement AgreementContentDTO `json:"seller_agreement"`
}
type PostRentalNoticeDTO struct {
Title string `json:"title"`
Content string `json:"content"`
}
type AgreementContentDTO struct {
Title string `json:"title"`
Content string `json:"content"`
}
// HomeBannerItem 首页轮播项:文案 + PC/移动两端视觉配置(图片、整图点击、热区)。
type HomeBannerItem struct {
Eyebrow string `json:"eyebrow"`
Title string `json:"title"`
Badge string `json:"badge"`
Pill string `json:"pill"`
Tone string `json:"tone"`
Enabled *bool `json:"enabled,omitempty"`
// ImageURL 早期扁平结构的图片字段,仅用于兼容读取:归一化时并入 PC/移动两端。
ImageURL string `json:"image_url,omitempty"`
PC HomeBannerVisual `json:"pc"`
Mobile HomeBannerVisual `json:"mobile"`
}
// HomeBannerVisual 单端展示配置:图片 + 整图点击行为 + 热区。
type HomeBannerVisual struct {
ImageURL string `json:"image_url,omitempty"`
Alt string `json:"alt,omitempty"`
Action HomeBannerAction `json:"action"`
Hotspots []HomeBannerHotspot `json:"hotspots,omitempty"`
}
// HomeBannerAction 点击行为:none(不跳转)/ route(站内路径)/ link(外部链接)/ preview(查看大图)。
type HomeBannerAction struct {
Type string `json:"type"`
Target string `json:"target"`
OpenNewTab bool `json:"open_new_tab"`
}
// HomeBannerHotspot 热区:相对图片的百分比坐标(0-100),与出图分辨率解耦。
type HomeBannerHotspot struct {
ID string `json:"id"`
Label string `json:"label"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
// ImageURL 热区按钮图(透明 PNG),留空则前台用半透明高亮框提示可点击。
ImageURL string `json:"image_url,omitempty"`
// HoverScale 悬浮放大倍数:1 表示不放大,建议 1.04-1.08。
HoverScale float64 `json:"hover_scale,omitempty"`
Action HomeBannerAction `json:"action"`
}
type PublishOptionsDTO struct {
// SchemaVersion 发布选项结构版本;缺省 0 代表未版本化的历史配置。
SchemaVersion int `json:"schema_version,omitempty"`
NextSeasonPassEnabled bool `json:"next_season_pass_enabled"`
ServerOptions []string `json:"server_options"`
FaceOptions []string `json:"face_options"`
RankOptions []string `json:"rank_options"`
InsuranceOptions []string `json:"insurance_options"`
LevelOptions []string `json:"level_options"`
LoginMethodOptions []string `json:"login_method_options"`
RegionOptions []string `json:"region_options"`
SkinGroups []PublishOptionGroup `json:"skin_groups"`
QuantityItems []PublishQuantityItem `json:"quantity_items"`
ScreenshotSlots []PublishScreenshotSlot `json:"screenshot_slots"`
BanRecordOptions []string `json:"ban_record_options"`
BanEvidenceOptions []string `json:"ban_evidence_options"`
FireLevelMin int `json:"fire_level_min"`
PriceConfig PublishPriceConfig `json:"price_config"`
DepositRecommend PublishDepositRecommend `json:"deposit_recommend_config"`
RatioConfig PublishRatioConfig `json:"ratio_config"`
}
type PublishOptionGroup struct {
Key string `json:"key"`
Title string `json:"title"`
Options []string `json:"options"`
}
type PublishQuantityItem struct {
Key string `json:"key"`
Label string `json:"label"`
Price string `json:"price"`
Placeholder string `json:"placeholder,omitempty"`
}
type PublishScreenshotSlot struct {
Key string `json:"key"`
Label string `json:"label"`
Required bool `json:"required"`
Hint string `json:"hint"`
}
type PublishPriceConfig struct {
DepositPlaceholder string `json:"deposit_placeholder"`
DepositHint string `json:"deposit_hint"`
PricePlaceholder string `json:"price_placeholder"`
RatioDescription string `json:"ratio_description"`
}
type PublishDepositRecommend struct {
BaseAmount float64 `json:"base_amount"`
SkinGroupRules []PublishDepositSkinGroupRule `json:"skin_group_rules"`
}
type PublishDepositSkinGroupRule struct {
GroupKey string `json:"group_key"`
Label string `json:"label"`
AmountPerItem float64 `json:"amount_per_item"`
}
type PublishRatioConfig struct {
InsuranceBaseRatios []PublishInsuranceBaseRatio `json:"insurance_base_ratios"`
ConfigItems []PublishRatioConfigItem `json:"config_items"`
CoinCorrections []PublishCoinCorrection `json:"coin_corrections"`
}
type PublishInsuranceBaseRatio struct {
Insurance string `json:"insurance"`
Ratio float64 `json:"ratio"`
}
type PublishRatioConfigItem struct {
Key string `json:"key"`
Label string `json:"label"`
Kind string `json:"kind"`
GroupKey string `json:"group_key,omitempty"`
MissingPenalty float64 `json:"missing_penalty"`
}
type PublishCoinCorrection struct {
ThresholdM float64 `json:"threshold_m"`
Correction float64 `json:"correction"`
}
type PublishSalePriceConfig struct {
FixedMarkupRules []PublishSaleFixedMarkupRule `json:"fixed_markup_rules"`
RatioAdjustmentRules []PublishSaleRatioAdjustmentRule `json:"ratio_adjustment_rules"`
}
type PublishSaleFixedMarkupRule struct {
MinM float64 `json:"min_m"`
MaxM float64 `json:"max_m"`
MarkupAmount float64 `json:"markup_amount"`
}
type PublishSaleRatioAdjustmentRule struct {
MinM float64 `json:"min_m"`
MaxM float64 `json:"max_m,omitempty"`
RatioSubtract float64 `json:"ratio_subtract"`
}
@@ -0,0 +1,169 @@
package systemconfig
import (
"errors"
"net/http"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/sitecode"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) PublishOptions(c *gin.Context) {
options, err := h.service.PublishOptions(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, options)
}
func (h *Handler) SalePriceConfig(c *gin.Context) {
config, err := h.service.SalePriceConfig(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, config)
}
func (h *Handler) OrderAgreements(c *gin.Context) {
agreements, err := h.service.OrderAgreements(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, agreements)
}
func (h *Handler) ListingPublishAgreements(c *gin.Context) {
agreements, err := h.service.ListingPublishAgreements(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, agreements)
}
func (h *Handler) PostRentalNotice(c *gin.Context) {
notice, err := h.service.PostRentalNotice(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, notice)
}
func (h *Handler) HomeAnnouncements(c *gin.Context) {
announcements, err := h.service.HomeAnnouncements(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, announcements)
}
func (h *Handler) HomeConfig(c *gin.Context) {
config, err := h.service.HomeConfig(c.Request.Context(), sitecode.FromRequest(c))
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, config)
}
func (h *Handler) Update(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
key := c.Param("key")
var req UpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "配置值不能为空")
return
}
item, err := h.service.Update(c.Request.Context(), adminID, key, req, auditMeta(c))
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) BackupSchedule(c *gin.Context) {
schedule, err := h.service.BackupSchedule(c.Request.Context())
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, schedule)
}
func (h *Handler) UpdateBackupSchedule(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req BackupScheduleDTO
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "备份定时配置格式无效")
return
}
schedule, err := h.service.UpdateBackupSchedule(c.Request.Context(), adminID, req, auditMeta(c))
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, schedule)
}
func auditMeta(c *gin.Context) AuditMeta {
return AuditMeta{
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
RequestID: middleware.GetRequestID(c),
}
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
func writeConfigError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidConfig):
response.BadRequest(c, "配置不符合规则")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "系统配置服务暂时不可用")
}
}
@@ -0,0 +1,59 @@
package systemconfig
import "encoding/json"
const homeAnnouncementsConfigKey = "mobile.home_announcements"
const homeBannersConfigKey = "mobile.home_banners"
func defaultHomeAnnouncements() []string {
return []string{
"平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。",
"优先推荐同地区账号,减少异地登录保护触发。",
"下单前请核对哈夫币、保险、体力负重和截图信息。",
}
}
func defaultHomeAnnouncementsConfigValue() string {
raw, err := json.Marshal(defaultHomeAnnouncements())
if err != nil {
return "[]"
}
return string(raw)
}
func defaultHomeBanners() []HomeBannerItem {
return []HomeBannerItem{
{
Eyebrow: "三角洲行动账号专区",
Title: "高哈夫币 · 安全交接 · 随租随玩",
Badge: "HOT",
Pill: "新人领券最高减 20 元",
Tone: "blue",
ImageURL: "",
},
{
Eyebrow: "平台担保交易",
Title: "交接全程留痕,拒绝私下转账",
Badge: "SAFE",
Pill: "租号前先验实名与资料",
Tone: "green",
ImageURL: "",
},
{
Eyebrow: "高效筛选",
Title: "按区服、段位、哈夫币快速找号",
Badge: "FAST",
Pill: "支持扫码号与账密号",
Tone: "orange",
ImageURL: "",
},
}
}
func defaultHomeBannersConfigValue() string {
raw, err := json.Marshal(defaultHomeBanners())
if err != nil {
return "[]"
}
return string(raw)
}
@@ -0,0 +1,122 @@
package systemconfig
import "testing"
func TestNormalizeHomeBannersMigratesLegacyImageURL(t *testing.T) {
items := normalizeHomeBanners([]HomeBannerItem{
{Title: "旧结构", ImageURL: " https://cdn.example/legacy.png "},
})
if len(items) != 1 {
t.Fatalf("期望保留 1 条轮播,实际 %d", len(items))
}
if items[0].PC.ImageURL != "https://cdn.example/legacy.png" {
t.Fatalf("旧图未迁移到 PC 端:%q", items[0].PC.ImageURL)
}
if items[0].Mobile.ImageURL != "https://cdn.example/legacy.png" {
t.Fatalf("旧图未迁移到移动端:%q", items[0].Mobile.ImageURL)
}
if items[0].ImageURL != "" {
t.Fatalf("旧字段应被清空,实际 %q", items[0].ImageURL)
}
}
func TestNormalizeHomeBannersKeepsPerPlatformVisual(t *testing.T) {
items := normalizeHomeBanners([]HomeBannerItem{{
Title: "两端分图",
PC: HomeBannerVisual{ImageURL: " https://cdn.example/pc.png "},
Mobile: HomeBannerVisual{ImageURL: " https://cdn.example/mobile.png "},
}})
if items[0].PC.ImageURL != "https://cdn.example/pc.png" {
t.Fatalf("PC 图被改动:%q", items[0].PC.ImageURL)
}
if items[0].Mobile.ImageURL != "https://cdn.example/mobile.png" {
t.Fatalf("移动端图被改动:%q", items[0].Mobile.ImageURL)
}
}
func TestNormalizeHomeBannersClampsHotspots(t *testing.T) {
items := normalizeHomeBanners([]HomeBannerItem{{
Title: "热区",
PC: HomeBannerVisual{Hotspots: []HomeBannerHotspot{
{ID: " h1 ", X: 90, Y: 95, Width: 20, Height: 20, Action: HomeBannerAction{Type: "route", Target: " /listings "}},
{ID: "zero", Width: 0, Height: 10, Action: HomeBannerAction{Type: "route", Target: "/x"}},
}},
}})
hotspots := items[0].PC.Hotspots
if len(hotspots) != 1 {
t.Fatalf("宽高为 0 的热区应被丢弃,剩余 %d 个", len(hotspots))
}
if hotspots[0].ID != "h1" || hotspots[0].X != 80 || hotspots[0].Y != 80 {
t.Fatalf("热区未收敛到合法范围:%+v", hotspots[0])
}
if hotspots[0].Action.Type != "route" || hotspots[0].Action.Target != "/listings" {
t.Fatalf("热区跳转未归一化:%+v", hotspots[0].Action)
}
}
func TestNormalizeHomeBannersHotspotImageAndHoverScale(t *testing.T) {
items := normalizeHomeBanners([]HomeBannerItem{{
Title: "热区按钮图",
PC: HomeBannerVisual{Hotspots: []HomeBannerHotspot{
{ID: "a", Width: 10, Height: 10, ImageURL: " https://cdn.example/btn.png "},
{ID: "b", Width: 10, Height: 10, HoverScale: 9},
{ID: "c", Width: 10, Height: 10, HoverScale: 0.5},
}},
}})
hotspots := items[0].PC.Hotspots
if hotspots[0].ImageURL != "https://cdn.example/btn.png" {
t.Fatalf("热区按钮图未清理空白:%q", hotspots[0].ImageURL)
}
if hotspots[0].HoverScale != defaultBannerHotspotHoverScale {
t.Fatalf("未配置悬浮倍数应回落默认值,实际 %v", hotspots[0].HoverScale)
}
if hotspots[1].HoverScale != maxBannerHotspotHoverScale {
t.Fatalf("超上限的悬浮倍数应被收敛,实际 %v", hotspots[1].HoverScale)
}
if hotspots[2].HoverScale != 1 {
t.Fatalf("小于 1 的悬浮倍数应收敛为 1,实际 %v", hotspots[2].HoverScale)
}
}
func TestNormalizeBannerActionFallsBackToNone(t *testing.T) {
cases := []struct {
name string
input HomeBannerAction
expect HomeBannerAction
}{
{
name: "缺失目标的站内跳转退化为不跳转",
input: HomeBannerAction{Type: "route", Target: " "},
expect: HomeBannerAction{Type: "none"},
},
{
name: "未知类型退化为不跳转",
input: HomeBannerAction{Type: "unknown", Target: "/x"},
expect: HomeBannerAction{Type: "none"},
},
{
name: "查看大图不保留目标与新窗口",
input: HomeBannerAction{Type: "preview", Target: "/x", OpenNewTab: true},
expect: HomeBannerAction{Type: "preview"},
},
{
name: "外链保留新窗口标记",
input: HomeBannerAction{Type: "link", Target: " https://cdn.example/a ", OpenNewTab: true},
expect: HomeBannerAction{Type: "link", Target: "https://cdn.example/a", OpenNewTab: true},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := normalizeBannerAction(testCase.input); got != testCase.expect {
t.Fatalf("期望 %+v,实际 %+v", testCase.expect, got)
}
})
}
}
func TestNormalizeHomeBannersFallsBackToDefaults(t *testing.T) {
items := normalizeHomeBanners([]HomeBannerItem{{Title: " "}})
if len(items) != len(defaultHomeBanners()) {
t.Fatalf("全部无效时应回落默认轮播,实际 %d 条", len(items))
}
}
@@ -0,0 +1,30 @@
package systemconfig
import "testing"
func TestDefaultHomeEntriesEnabled(t *testing.T) {
if got := defaultHomeEntriesEnabledConfigValue(); got != "true" {
t.Fatalf("默认首页业务入口开关 = %q, want true", got)
}
}
func TestParseHomeEntriesEnabled(t *testing.T) {
cases := []struct {
value string
want bool
}{
{value: "true", want: true},
{value: " TRUE ", want: true},
{value: "1", want: true},
{value: "false", want: false},
{value: "0", want: false},
{value: "", want: false},
{value: "unknown", want: false},
}
for _, item := range cases {
if got := parseHomeEntriesEnabled(item.value); got != item.want {
t.Fatalf("parseHomeEntriesEnabled(%q) = %v, want %v", item.value, got, item.want)
}
}
}
@@ -0,0 +1,23 @@
package systemconfig
import "testing"
func TestParseHomeEntryOverrideInheritsWhenEmpty(t *testing.T) {
if _, ok := parseAWRecycleOverride(" "); ok {
t.Fatal("空配置应继承主站")
}
if _, ok := parseCooperationFeedbackOverride(""); ok {
t.Fatal("空配置应继承主站")
}
}
func TestParseHomeEntryOverrideUsesSiteJSON(t *testing.T) {
aw, ok := parseAWRecycleOverride(`{"title":"分站回收","subtitle":"看图","image_url":"/aw.png","enabled":true}`)
if !ok || aw.Title != "分站回收" || aw.ImageURL != "/aw.png" {
t.Fatalf("回收覆盖 = %+v ok=%v", aw, ok)
}
coop, ok := parseCooperationFeedbackOverride(`{"title":"分站合作","subtitle":"联系","image_url":"/coop.png","enabled":false}`)
if !ok || coop.Title != "分站合作" || coop.Enabled {
t.Fatalf("合作覆盖 = %+v ok=%v", coop, ok)
}
}
@@ -0,0 +1,56 @@
package systemconfig
import "encoding/json"
const listingPublishAgreementsConfigKey = "listing.publish_agreements"
func defaultListingPublishAgreementsConfigValue() string {
raw, err := json.Marshal(DefaultListingPublishAgreements())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultListingPublishAgreements() ListingPublishAgreementsDTO {
return ListingPublishAgreementsDTO{
VirtualAssetSale: AgreementContentDTO{
Title: "虚拟资产出售协议",
Content: `请您在发布账号前仔细阅读并确认以下内容:
一、发布性质
1. 您发布的账号、哈夫币、皮肤、装备、消耗品等均属于游戏内虚拟资产或使用权益展示。
2. 虚拟资产受游戏厂商规则、版本更新、风控策略和账号状态影响,平台不承诺其具备现实货币价值或永久可用性。
3. 您应确保发布信息真实、完整、可交接,且与截图材料和账号实际资产一致。
二、风险告知
1. 发布账号后,买家下单前后可能因游戏环境变化、资产变动、封禁记录、登录限制等产生交易风险。
2. 若您隐瞒账号异常、资产缺失、封禁记录、不可用物资或其他影响交易的信息,平台可依据证据处理赔付、退款或下架。
3. 请勿绕过平台私下收款、私下交接或诱导用户脱离平台沟通,否则平台可能限制账号发布权限。
三、费用与结算
1. 发布价格、押金、额外消耗品价值以平台发布页和订单页面展示为准。
2. 订单完成后,平台将按订单记录、结账结果和相关规则进行结算。
3. 若发生争议,平台将依据订单记录、资产快照、聊天记录、截图证据和双方说明进行处理。`,
},
SellerAgreement: AgreementContentDTO{
Title: "号主协议",
Content: `请您作为号主在发布账号前确认并遵守以下约定:
一、账号发布义务
1. 您应如实填写区服、段位、哈夫币数量、保险、体力、负重、登录方式、封禁记录、常用地区和备注信息。
2. 您应上传真实、清晰、可核验的账号截图材料,不得使用伪造、过期或与账号不一致的截图。
3. 您不得发布盗号、黑号、纠纷账号、已被限制使用账号或无法完成正常交接的账号。
二、交接与协作
1. 买家下单后,您应在平台要求时间内完成交接,及时响应扫码、人脸冻结、登录验证等流程。
2. 您应在租用期间保持可联系状态,不得无故拒绝交接、恶意拖延或私下更改交易条件。
3. 租用结束后,请按平台流程确认归还和结账,配合核验资产状态。
三、责任承担
1. 因您发布信息不实、交接不及时、账号异常隐瞒或私下交易造成的损失,平台可依据规则处理退款、赔付、下架或限制发布。
2. 因租客违规使用造成的损失,您应通过平台流程提交证据并配合争议处理。
3. 您同意平台依据订单记录、资产快照、聊天记录、截图证据和相关规则处理结算、赔付及争议。`,
},
}
}
@@ -0,0 +1,265 @@
package systemconfig
import (
"math"
"strings"
)
// 热区悬浮放大倍数:与前端 BANNER_HOTSPOT_DEFAULT_HOVER_SCALE 保持一致。
const defaultBannerHotspotHoverScale = 1.06
const maxBannerHotspotHoverScale = 1.5
func normalizeAnnouncements(items []string) []string {
normalized := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item != "" {
normalized = append(normalized, item)
}
}
if len(normalized) == 0 {
return defaultHomeAnnouncements()
}
return normalized
}
func normalizeHomeBanners(items []HomeBannerItem) []HomeBannerItem {
normalized := make([]HomeBannerItem, 0, len(items))
for _, item := range items {
item.Eyebrow = strings.TrimSpace(item.Eyebrow)
item.Title = strings.TrimSpace(item.Title)
item.Badge = strings.TrimSpace(item.Badge)
item.Pill = strings.TrimSpace(item.Pill)
item.Tone = strings.TrimSpace(item.Tone)
// 旧结构只有一张图,两端共用,避免历史配置升级后丢图
legacyImageURL := strings.TrimSpace(item.ImageURL)
item.ImageURL = ""
item.PC = normalizeBannerVisual(item.PC, legacyImageURL)
item.Mobile = normalizeBannerVisual(item.Mobile, legacyImageURL)
if item.Title == "" && item.PC.ImageURL == "" && item.Mobile.ImageURL == "" {
continue
}
if item.Tone != "green" && item.Tone != "orange" && item.Tone != "gold" {
item.Tone = "blue"
}
normalized = append(normalized, item)
}
if len(normalized) == 0 {
return defaultHomeBanners()
}
return normalized
}
func normalizeBannerVisual(visual HomeBannerVisual, legacyImageURL string) HomeBannerVisual {
visual.ImageURL = strings.TrimSpace(visual.ImageURL)
if visual.ImageURL == "" {
visual.ImageURL = legacyImageURL
}
visual.Alt = strings.TrimSpace(visual.Alt)
visual.Action = normalizeBannerAction(visual.Action)
hotspots := make([]HomeBannerHotspot, 0, len(visual.Hotspots))
for _, hotspot := range visual.Hotspots {
item, ok := normalizeBannerHotspot(hotspot)
if !ok {
continue
}
hotspots = append(hotspots, item)
}
visual.Hotspots = hotspots
return visual
}
// normalizeBannerHotspot 收敛热区坐标:宽高为 0 的热区无意义直接丢弃,其余保证不越界。
func normalizeBannerHotspot(hotspot HomeBannerHotspot) (HomeBannerHotspot, bool) {
width := clampBannerPercent(hotspot.Width, 100)
height := clampBannerPercent(hotspot.Height, 100)
if width <= 0 || height <= 0 {
return HomeBannerHotspot{}, false
}
hotspot.ID = strings.TrimSpace(hotspot.ID)
hotspot.Label = strings.TrimSpace(hotspot.Label)
hotspot.ImageURL = strings.TrimSpace(hotspot.ImageURL)
hotspot.HoverScale = normalizeHotspotHoverScale(hotspot.HoverScale)
hotspot.Width = width
hotspot.Height = height
hotspot.X = clampBannerPercent(hotspot.X, 100-width)
hotspot.Y = clampBannerPercent(hotspot.Y, 100-height)
hotspot.Action = normalizeBannerAction(hotspot.Action)
return hotspot, true
}
// normalizeHotspotHoverScale 悬浮放大倍数:缺失/非法回落默认值,上限 1.5 防止热区盖住整张图。
func normalizeHotspotHoverScale(value float64) float64 {
if value <= 0 {
return defaultBannerHotspotHoverScale
}
if value > maxBannerHotspotHoverScale {
value = maxBannerHotspotHoverScale
} else if value < 1 {
value = 1
}
return math.Round(value*100) / 100
}
// normalizeBannerAction 目标缺失的跳转会变成「点了没反应」的死热区,统一退化为 none。
func normalizeBannerAction(action HomeBannerAction) HomeBannerAction {
action.Type = strings.TrimSpace(action.Type)
action.Target = strings.TrimSpace(action.Target)
switch action.Type {
case "route", "link":
if action.Target == "" {
return HomeBannerAction{Type: "none"}
}
case "preview":
action.Target = ""
action.OpenNewTab = false
case "none":
action.Target = ""
action.OpenNewTab = false
default:
return HomeBannerAction{Type: "none"}
}
return action
}
func clampBannerPercent(value float64, max float64) float64 {
if value < 0 {
value = 0
}
if value > max {
value = max
}
return math.Round(value*100) / 100
}
func normalizeOrderAgreements(agreements *OrderAgreementsDTO) {
defaults := DefaultOrderAgreements()
agreements.VirtualAssetPurchase.Title = strings.TrimSpace(agreements.VirtualAssetPurchase.Title)
agreements.VirtualAssetPurchase.Content = strings.TrimSpace(agreements.VirtualAssetPurchase.Content)
agreements.RenterAgreement.Title = strings.TrimSpace(agreements.RenterAgreement.Title)
agreements.RenterAgreement.Content = strings.TrimSpace(agreements.RenterAgreement.Content)
if agreements.VirtualAssetPurchase.Title == "" {
agreements.VirtualAssetPurchase.Title = defaults.VirtualAssetPurchase.Title
}
if agreements.VirtualAssetPurchase.Content == "" {
agreements.VirtualAssetPurchase.Content = defaults.VirtualAssetPurchase.Content
}
if agreements.RenterAgreement.Title == "" {
agreements.RenterAgreement.Title = defaults.RenterAgreement.Title
}
if agreements.RenterAgreement.Content == "" {
agreements.RenterAgreement.Content = defaults.RenterAgreement.Content
}
}
func normalizeListingPublishAgreements(agreements *ListingPublishAgreementsDTO) {
defaults := DefaultListingPublishAgreements()
agreements.VirtualAssetSale.Title = strings.TrimSpace(agreements.VirtualAssetSale.Title)
agreements.VirtualAssetSale.Content = strings.TrimSpace(agreements.VirtualAssetSale.Content)
agreements.SellerAgreement.Title = strings.TrimSpace(agreements.SellerAgreement.Title)
agreements.SellerAgreement.Content = strings.TrimSpace(agreements.SellerAgreement.Content)
if agreements.VirtualAssetSale.Title == "" {
agreements.VirtualAssetSale.Title = defaults.VirtualAssetSale.Title
}
if agreements.VirtualAssetSale.Content == "" {
agreements.VirtualAssetSale.Content = defaults.VirtualAssetSale.Content
}
if agreements.SellerAgreement.Title == "" {
agreements.SellerAgreement.Title = defaults.SellerAgreement.Title
}
if agreements.SellerAgreement.Content == "" {
agreements.SellerAgreement.Content = defaults.SellerAgreement.Content
}
}
func normalizePostRentalNotice(notice *PostRentalNoticeDTO) {
defaults := DefaultPostRentalNotice()
notice.Title = strings.TrimSpace(notice.Title)
notice.Content = strings.TrimSpace(notice.Content)
if notice.Title == "" {
notice.Title = defaults.Title
}
if notice.Content == "" {
notice.Content = defaults.Content
}
}
func normalizePublishOptions(options *PublishOptionsDTO) {
defaults := DefaultPublishOptions()
if len(options.ServerOptions) == 0 {
options.ServerOptions = defaults.ServerOptions
}
if len(options.FaceOptions) == 0 {
options.FaceOptions = defaults.FaceOptions
}
if len(options.RankOptions) == 0 {
options.RankOptions = defaults.RankOptions
}
if len(options.InsuranceOptions) == 0 {
options.InsuranceOptions = defaults.InsuranceOptions
}
if len(options.LevelOptions) == 0 {
options.LevelOptions = defaults.LevelOptions
}
if len(options.LoginMethodOptions) == 0 {
options.LoginMethodOptions = defaults.LoginMethodOptions
}
if len(options.RegionOptions) == 0 {
options.RegionOptions = defaults.RegionOptions
}
if len(options.SkinGroups) == 0 {
options.SkinGroups = defaults.SkinGroups
}
if len(options.QuantityItems) == 0 {
options.QuantityItems = defaults.QuantityItems
}
if len(options.ScreenshotSlots) == 0 {
options.ScreenshotSlots = defaults.ScreenshotSlots
}
if len(options.BanRecordOptions) == 0 {
options.BanRecordOptions = defaults.BanRecordOptions
}
if len(options.BanEvidenceOptions) == 0 {
options.BanEvidenceOptions = defaults.BanEvidenceOptions
}
if options.FireLevelMin <= 0 {
options.FireLevelMin = defaults.FireLevelMin
}
if options.PriceConfig.DepositPlaceholder == "" {
options.PriceConfig.DepositPlaceholder = defaults.PriceConfig.DepositPlaceholder
}
if options.PriceConfig.DepositHint == "" {
options.PriceConfig.DepositHint = defaults.PriceConfig.DepositHint
}
if options.PriceConfig.PricePlaceholder == "" {
options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder
}
if options.PriceConfig.RatioDescription == "" {
options.PriceConfig.RatioDescription = defaults.PriceConfig.RatioDescription
}
if options.DepositRecommend.BaseAmount <= 0 {
options.DepositRecommend.BaseAmount = defaults.DepositRecommend.BaseAmount
}
if len(options.DepositRecommend.SkinGroupRules) == 0 {
options.DepositRecommend.SkinGroupRules = defaults.DepositRecommend.SkinGroupRules
}
if len(options.RatioConfig.InsuranceBaseRatios) == 0 {
options.RatioConfig.InsuranceBaseRatios = defaults.RatioConfig.InsuranceBaseRatios
}
if len(options.RatioConfig.ConfigItems) == 0 {
options.RatioConfig.ConfigItems = defaults.RatioConfig.ConfigItems
}
if len(options.RatioConfig.CoinCorrections) == 0 {
options.RatioConfig.CoinCorrections = defaults.RatioConfig.CoinCorrections
}
}
func normalizeSalePriceConfig(config *PublishSalePriceConfig) {
defaults := DefaultSalePriceConfig()
if len(config.FixedMarkupRules) == 0 {
config.FixedMarkupRules = defaults.FixedMarkupRules
}
if len(config.RatioAdjustmentRules) == 0 {
config.RatioAdjustmentRules = defaults.RatioAdjustmentRules
}
}
@@ -0,0 +1,56 @@
package systemconfig
import "encoding/json"
const orderAgreementsConfigKey = "order.agreements"
func defaultOrderAgreementsConfigValue() string {
raw, err := json.Marshal(DefaultOrderAgreements())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultOrderAgreements() OrderAgreementsDTO {
return OrderAgreementsDTO{
VirtualAssetPurchase: AgreementContentDTO{
Title: "虚拟资产购买协议",
Content: `请您在下单前仔细阅读并确认以下内容:
一、交易性质
1. 本平台商品涉及游戏账号租用期间的虚拟资产使用权益、租金和押金托管服务。
2. 虚拟资产、游戏账号及相关数据均受游戏厂商规则约束,平台不承诺虚拟资产具备现实货币价值或永久可用性。
3. 您下单即表示已理解商品页面展示的哈夫币、装备、皮肤、消耗品、租期、押金和交接规则。
二、风险告知
1. 游戏厂商可能因版本更新、风控策略、账号安全策略等因素调整游戏资产状态。
2. 因租客违规操作、使用外挂、恶意消耗、转移资产、私下交易或违反游戏规则造成的损失,可能从押金中扣除或进入争议处理。
3. 请勿绕过平台私下付款、私下交接或私下协商退款,否则平台可能无法保障您的资金与交易权益。
三、费用与结算
1. 订单租金、押金和可能产生的额外消耗品费用以平台订单和结账页面展示为准。
2. 未实际使用完的租金或剩余押金,将按平台结账规则退回。
3. 若发生争议,平台将依据订单记录、聊天记录、截图证据和双方说明进行处理。`,
},
RenterAgreement: AgreementContentDTO{
Title: "租客协议",
Content: `请您作为租客在下单前确认并遵守以下约定:
一、账号使用义务
1. 您仅可在订单约定时间内按商品说明使用账号,不得转租、转借、出售、赠与或用于任何非订单目的。
2. 您不得使用外挂、脚本、漏洞、恶意组队、违规交易、消极比赛、辱骂举报等可能导致账号异常的行为。
3. 您不得修改账号绑定信息、登录凭证、安全设置、实名信息或任何影响号主取回账号的内容。
二、资产保护义务
1. 您应按商品详情和订单快照合理使用哈夫币、装备、物资、皮肤及其他虚拟资产。
2. 额外消耗品、哈夫币消耗、违规损失、封禁损失等应在结账时如实登记。
3. 因您未按约定使用账号造成的损失,平台可根据证据从押金中扣除相应赔付。
三、交接与结账
1. 下单后请通过平台流程完成交接、沟通、归还和结账,不要私下交易。
2. 租用结束后请及时发起结账并说明资产状态;号主确认或双方完成争议处理后,订单结算完成。
3. 您同意平台依据订单记录、资产快照、聊天记录、截图证据和相关规则处理退款、赔付及争议。`,
},
}
}
@@ -0,0 +1,52 @@
package systemconfig
import "encoding/json"
const postRentalNoticeConfigKey = "profile.post_rental_notice"
func defaultPostRentalNoticeConfigValue() string {
raw, err := json.Marshal(DefaultPostRentalNotice())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultPostRentalNotice() PostRentalNoticeDTO {
return PostRentalNoticeDTO{
Title: "租后须知(重要!)",
Content: `一、上号前须知
1. 租期说明
租期内无法退租(除非发现不可抗力),如遇游戏的风控策略调整导致账号异常,如账号主动提交过人脸识别认证(按交易协议比例赔偿)。
2. 账号信息不齐
租客流需在首次上号后10分钟完成验号,超时则单次以租期已开始计算,此后中断与异常问题由租客自行承担。
仅以在首次上号10分钟之前完成查验;
未上号马号主与关联等,如果出现异常抢拍后经过时,除押金全额返还后,可能会影响结账比例规则判定。
二、账号使用规范
3. 切勿使用规制
账号内绑"绑币"外,其它物品谨慎消耗,送意开货时用品、子弹包、资源包、全能包、合金包。
活动赠送或抽奖券及二次充值。
若账号因租客违规操作、使用外挂、恶意消耗、转移资产或违反游戏规则造成损失,可能从押金中扣除或进入争议处理,根本扣除额按按违规严重程度而定。
4. 禁止转租
不可转租、转借或用于任何非订单目的。
号主无法输入人脸验证;按交接规则卫按级号号不得修改账号绑定信息或任何影响号主取回账号的内容。
三、封禁处理规则
5. 账号封禁
账号封禁判责规则按"按照订单记录、截图证据、聊天记录和双方说明"进行判断,若租客操作符合规则但仍封禁,须按封禁情况审核后按实际损失或押金比例约40%补偿号主。
不可转按换现金或按时归还后不予退租,按照平台规则处理退款和赔付。`,
}
}
@@ -0,0 +1,126 @@
package systemconfig
import "encoding/json"
const (
publishOptionsConfigKey = "listing.publish_options"
publishOptionsSchemaVersion = 1
)
func defaultPublishOptionsConfigValue() string {
raw, err := json.Marshal(DefaultPublishOptions())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultPublishOptions() PublishOptionsDTO {
return PublishOptionsDTO{
SchemaVersion: publishOptionsSchemaVersion,
NextSeasonPassEnabled: false,
ServerOptions: []string{"QQ", "微信", "Steam"},
FaceOptions: []string{"是", "否"},
RankOptions: []string{"青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "三角洲巅峰"},
InsuranceOptions: []string{"2*1", "2*2", "2*3", "3*3"},
LevelOptions: []string{"1级", "2级", "3级", "4级", "5级", "6级", "7级"},
LoginMethodOptions: []string{"扫码", "账号密码"},
RegionOptions: []string{
"北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江",
"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南",
"湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州",
"云南", "陕西", "甘肃", "青海", "宁夏", "新疆", "西藏", "香港",
"澳门", "台湾",
},
SkinGroups: []PublishOptionGroup{
{
Key: "melee",
Title: "近战皮肤",
Options: []string{
"坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者",
},
},
{
Key: "operatorGold",
Title: "干员金皮",
Options: []string{
"露娜-古墓丽影联动", "蛊-不羁人生", "红狼-电锯惊魂", "露娜-金牌射手",
"牧羊人-街头之星", "蜂医-危险物质", "蜂医-送葬人", "无名-夜鹰",
"威龙-壮志凌云", "威龙-蛟龙特战队", "威龙-铁面判官", "威龙-吴彦祖",
},
},
{
Key: "operatorRed",
Title: "干员红皮",
Options: []string{
"凌霄成卫", "蚀金玫瑰", "水墨云图", "午夜邮差", "天际线", "维什戴尔",
},
},
{
Key: "weapon",
Title: "武器皮肤",
Options: []string{
"M7棱镜攻势S2", "腾龙气象感应", "MP7电玩高手S2", "M250电玩高手S2",
"AS Val悬赏令", "K416命运", "M4A1棱镜攻势", "SCAR-H电玩高手",
"QBZ95王牌之剑", "AUG气象感应", "Vector美杜莎", "KC17-造物纪元",
},
},
},
QuantityItems: []PublishQuantityItem{
{Key: "level6Ammo", Label: "6级弹", Price: "0.1元/发", Placeholder: "按单发数量填写,60发=6元"},
{Key: "awmAmmo", Label: "AWM子弹", Price: "0.6元/发"},
{Key: "barrettAmmo", Label: "巴雷特子弹", Price: "0.3元/发"},
{Key: "armor6", Label: "6甲", Price: "2.5元/个"},
{Key: "helmet6", Label: "6头", Price: "1.5元/个"},
{Key: "gridCard9", Label: "9格体验卡", Price: "3元/张", Placeholder: "如果是赛季9格,此处填0即可"},
{Key: "redPacket45", Label: "45格大红包", Price: "1.5元/个"},
{Key: "coffeeBean", Label: "高级咖啡豆", Price: "2.5元/个"},
{Key: "bulletPart", Label: "高级子弹零件", Price: "3元/个"},
},
ScreenshotSlots: []PublishScreenshotSlot{
{Key: "coin", Label: "纯币截图", Required: true, Hint: "请上传纯币截图(必传)"},
{Key: "gameId", Label: "游戏ID截图", Required: true, Hint: "请上传游戏ID截图(必传)"},
{Key: "totalAsset", Label: "总资产截图", Required: true, Hint: "请上传总资产截图(必传)"},
{Key: "tencentSecurity", Label: "腾讯安全中心截图", Required: false, Hint: "有封禁记录时必传"},
{Key: "skin", Label: "皮肤截图", Required: false, Hint: "请上传皮肤截图(选传)"},
},
BanRecordOptions: []string{"无封禁记录", "有封禁记录"},
BanEvidenceOptions: []string{"有封禁记录"},
FireLevelMin: 38,
PriceConfig: PublishPriceConfig{
DepositPlaceholder: "温馨提示:本押金用于保障账号安全。若买家在租用期间,使用非纯币类道具/资源(如消耗品、材料等)或导致账号被封禁,相关损失将直接从押金中扣除进行赔付。押金过高会延长出租等待时间(请自行衡量)感谢理解。",
DepositHint: "租客缴纳,封号必赔付",
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
},
DepositRecommend: PublishDepositRecommend{
BaseAmount: 50,
SkinGroupRules: []PublishDepositSkinGroupRule{
{GroupKey: "melee", Label: "刀皮", AmountPerItem: 5},
{GroupKey: "operatorGold", Label: "干员金皮", AmountPerItem: 10},
{GroupKey: "operatorRed", Label: "干员红皮", AmountPerItem: 30},
},
},
RatioConfig: PublishRatioConfig{
InsuranceBaseRatios: []PublishInsuranceBaseRatio{
{Insurance: "3*3", Ratio: 40},
{Insurance: "2*3", Ratio: 42},
{Insurance: "2*2", Ratio: 47},
{Insurance: "2*1", Ratio: 48},
},
ConfigItems: []PublishRatioConfigItem{
{Key: "operatorRed", Label: "干员红皮", Kind: "skin_group", GroupKey: "operatorRed", MissingPenalty: 1},
{Key: "melee", Label: "刀皮", Kind: "skin_group", GroupKey: "melee", MissingPenalty: 1},
{Key: "staminaMax", Label: "满体力", Kind: "max_stamina", MissingPenalty: 1},
{Key: "loadMax", Label: "满负重", Kind: "max_load", MissingPenalty: 1},
{Key: "weapon", Label: "砖皮", Kind: "skin_group", GroupKey: "weapon", MissingPenalty: 1},
},
CoinCorrections: []PublishCoinCorrection{
{ThresholdM: 130, Correction: 1},
{ThresholdM: 230, Correction: 2.5},
{ThresholdM: 380, Correction: 4},
{ThresholdM: 530, Correction: 5},
},
},
}
}
@@ -0,0 +1,161 @@
package systemconfig
import (
"encoding/json"
"strings"
"testing"
)
func TestDecodeCP1252Mojibake(t *testing.T) {
original := `{"label":"6级子弹","price":"0.26元/发","punct":"(:,)"}`
mojibake := encodeCP1252Mojibake(original)
decoded, ok := decodeCP1252Mojibake(mojibake)
if !ok {
t.Fatal("期望识别出 CP1252 乱码")
}
if decoded != original {
t.Fatalf("还原结果 = %q, want %q", decoded, original)
}
if _, ok := decodeCP1252Mojibake(original); ok {
t.Fatal("正常中文不应被误判为乱码")
}
if _, ok := decodeCP1252Mojibake("plain ascii"); ok {
t.Fatal("纯 ASCII 不应被误判为乱码")
}
}
func TestRepairMojibakeJSONRepairsIndividualFields(t *testing.T) {
raw := `{"normal":"正常字段","broken":"` + encodeCP1252Mojibake("6级子弹") + `"}`
repaired := repairMojibakeJSON(raw)
var payload map[string]string
if err := json.Unmarshal([]byte(repaired), &payload); err != nil {
t.Fatalf("还原后的 JSON 无法解析: %v", err)
}
if payload["normal"] != "正常字段" {
t.Fatalf("正常字段被破坏: %q", payload["normal"])
}
if payload["broken"] != "6级子弹" {
t.Fatalf("乱码字段未还原: %q", payload["broken"])
}
}
func TestNormalizePublishOptionsValueMigratesLegacyQuantityItems(t *testing.T) {
legacy := `{
"server_options": ["QQ"],
"quantity_items": [
{"key": "kit5", "label": "5级全装套数量", "price": "5元/个"},
{"key": "level6Ammo", "label": "6级子弹", "price": "0.26元/发"}
]
}`
normalized, err := normalizePublishOptionsValue(legacy)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &options); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if options.SchemaVersion != publishOptionsSchemaVersion {
t.Fatalf("schema_version = %d, want %d", options.SchemaVersion, publishOptionsSchemaVersion)
}
for _, item := range options.QuantityItems {
if item.Key == "kit5" {
t.Fatal("旧版 kit5 选项应被迁移掉")
}
}
if !hasQuantityItemKey(options.QuantityItems, "level6Ammo") {
t.Fatal("迁移后的默认选项缺少 level6Ammo")
}
}
func TestNormalizePublishOptionsValuePreservesCurrentSchema(t *testing.T) {
options := DefaultPublishOptions()
for index := range options.ScreenshotSlots {
if options.ScreenshotSlots[index].Key == "tencentSecurity" {
options.ScreenshotSlots[index].Required = true
}
}
options.QuantityItems = append(options.QuantityItems, PublishQuantityItem{
Key: "kit5",
Label: "自定义旧选项",
Price: "9元/个",
})
raw, err := json.Marshal(options)
if err != nil {
t.Fatalf("构造当前版本配置失败: %v", err)
}
normalized, err := normalizePublishOptionsValue(string(raw))
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var got PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &got); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !hasQuantityItemKey(got.QuantityItems, "kit5") {
t.Fatal("当前版本配置中的自定义项不应被旧版迁移清掉")
}
for _, slot := range got.ScreenshotSlots {
if slot.Key == "tencentSecurity" && !slot.Required {
t.Fatal("当前版本配置中 tencentSecurity.required=true 不应被覆盖")
}
}
}
func TestNormalizePublishOptionsValueRepairsMojibake(t *testing.T) {
options := DefaultPublishOptions()
raw, err := json.Marshal(options)
if err != nil {
t.Fatalf("构造配置失败: %v", err)
}
mojibake := encodeCP1252Mojibake(string(raw))
normalized, err := normalizePublishOptionsValue(mojibake)
if err != nil {
t.Fatalf("规范化乱码配置失败: %v", err)
}
var got PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &got); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !containsString(got.ServerOptions, "微信") {
t.Fatalf("乱码未恢复,server_options = %#v", got.ServerOptions)
}
if got.SchemaVersion != publishOptionsSchemaVersion {
t.Fatalf("schema_version = %d, want %d", got.SchemaVersion, publishOptionsSchemaVersion)
}
}
func encodeCP1252Mojibake(value string) string {
var builder strings.Builder
for _, item := range []byte(value) {
if item >= 0x80 && item <= 0x9F {
builder.WriteRune(cp1252HighRunes[item-0x80])
continue
}
builder.WriteRune(rune(item))
}
return builder.String()
}
func hasQuantityItemKey(items []PublishQuantityItem, key string) bool {
for _, item := range items {
if item.Key == key {
return true
}
}
return false
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
@@ -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,
}
}
@@ -0,0 +1,65 @@
package systemconfig
import (
"context"
"fmt"
"sync"
"testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func newTestSystemConfigRepo(t *testing.T) (*Repository, *gorm.DB) {
t.Helper()
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.SystemConfig{}); err != nil {
t.Fatalf("自动迁移 SystemConfig 失败: %v", err)
}
return NewRepository(db), db
}
func TestRepositoryEnsureDefaultsConcurrency(t *testing.T) {
repo, _ := newTestSystemConfigRepo(t)
ctx := context.Background()
// 并发 20 个 goroutine 调用 EnsureDefaults
var wg sync.WaitGroup
errCh := make(chan error, 20)
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := repo.EnsureDefaults(ctx); err != nil {
errCh <- err
}
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("并发 EnsureDefaults 失败: %v", err)
}
}
// 再次并发调用 FindValue,验证 initialized 为 true 时直接走只读路径
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
val, err := repo.FindValue(ctx, "handoff.owner_submit_timeout_minutes")
if err != nil {
errCh <- err
return
}
if val != "30" {
errCh <- fmt.Errorf("got %s, want 30", val)
}
}()
}
wg.Wait()
}
@@ -0,0 +1,33 @@
package systemconfig
import "encoding/json"
const salePriceConfigKey = "listing.sale_price_config"
func defaultSalePriceConfigValue() string {
raw, err := json.Marshal(DefaultSalePriceConfig())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultSalePriceConfig() PublishSalePriceConfig {
return PublishSalePriceConfig{
FixedMarkupRules: []PublishSaleFixedMarkupRule{
{MinM: 10, MaxM: 30, MarkupAmount: 28},
{MinM: 30, MaxM: 50, MarkupAmount: 31},
{MinM: 50, MaxM: 70, MarkupAmount: 34},
{MinM: 70, MaxM: 90, MarkupAmount: 38},
},
RatioAdjustmentRules: []PublishSaleRatioAdjustmentRule{
{MinM: 90, MaxM: 150, RatioSubtract: 5},
{MinM: 150, MaxM: 230, RatioSubtract: 4},
{MinM: 230, MaxM: 310, RatioSubtract: 3.5},
{MinM: 310, MaxM: 390, RatioSubtract: 3},
{MinM: 390, MaxM: 470, RatioSubtract: 0},
{MinM: 470, MaxM: 550, RatioSubtract: 0},
{MinM: 550, RatioSubtract: 0},
},
}
}
@@ -0,0 +1,55 @@
package systemconfig
import (
"context"
"errors"
"strconv"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidConfig = errors.New("invalid config")
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(ctx context.Context) ([]ConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.List(ctx)
}
func (s *Service) BackupSchedule(ctx context.Context) (*BackupScheduleDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
values := map[string]string{}
for _, item := range defaultConfigs {
if item.Key == "backup.schedule_enabled" || item.Key == "backup.full_hour" || item.Key == "backup.full_minute" || item.Key == "backup.binlog_interval_minutes" {
values[item.Key] = item.Value
}
}
for key := range values {
value, err := s.repo.FindValue(ctx, key)
if err != nil {
return nil, err
}
values[key] = value
}
hour, _ := strconv.Atoi(values["backup.full_hour"])
minute, _ := strconv.Atoi(values["backup.full_minute"])
interval, _ := strconv.Atoi(values["backup.binlog_interval_minutes"])
return &BackupScheduleDTO{
Enabled: values["backup.schedule_enabled"] == "true",
FullHour: hour,
FullMinute: minute,
BinlogIntervalMinutes: interval,
}, nil
}
@@ -0,0 +1,126 @@
package systemconfig
import (
"context"
"encoding/json"
)
func (s *Service) PublishOptions(ctx context.Context) (*PublishOptionsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
options, err := s.publishOptions(ctx)
if err != nil {
return nil, err
}
return &options, nil
}
func (s *Service) SalePriceConfig(ctx context.Context) (*PublishSalePriceConfig, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
config, err := s.salePriceConfig(ctx)
if err != nil {
return nil, err
}
return &config, nil
}
func (s *Service) OrderAgreements(ctx context.Context) (*OrderAgreementsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
agreements, err := s.orderAgreements(ctx)
if err != nil {
return nil, err
}
return &agreements, nil
}
func (s *Service) ListingPublishAgreements(ctx context.Context) (*ListingPublishAgreementsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
agreements, err := s.listingPublishAgreements(ctx)
if err != nil {
return nil, err
}
return &agreements, nil
}
func (s *Service) PostRentalNotice(ctx context.Context) (*PostRentalNoticeDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
notice, err := s.postRentalNotice(ctx)
if err != nil {
return nil, err
}
return &notice, nil
}
func (s *Service) publishOptions(ctx context.Context) (PublishOptionsDTO, error) {
value, err := s.repo.FindValue(ctx, publishOptionsConfigKey)
if err != nil {
return PublishOptionsDTO{}, err
}
options := DefaultPublishOptions()
if err := json.Unmarshal([]byte(value), &options); err != nil {
options = DefaultPublishOptions()
}
normalizePublishOptions(&options)
return options, nil
}
func (s *Service) salePriceConfig(ctx context.Context) (PublishSalePriceConfig, error) {
value, err := s.repo.FindValue(ctx, salePriceConfigKey)
if err != nil {
return PublishSalePriceConfig{}, err
}
config := DefaultSalePriceConfig()
if err := json.Unmarshal([]byte(value), &config); err != nil {
config = DefaultSalePriceConfig()
}
normalizeSalePriceConfig(&config)
return config, nil
}
func (s *Service) orderAgreements(ctx context.Context) (OrderAgreementsDTO, error) {
value, err := s.repo.FindValue(ctx, orderAgreementsConfigKey)
if err != nil {
return OrderAgreementsDTO{}, err
}
agreements := DefaultOrderAgreements()
if err := json.Unmarshal([]byte(value), &agreements); err != nil {
agreements = DefaultOrderAgreements()
}
normalizeOrderAgreements(&agreements)
return agreements, nil
}
func (s *Service) listingPublishAgreements(ctx context.Context) (ListingPublishAgreementsDTO, error) {
value, err := s.repo.FindValue(ctx, listingPublishAgreementsConfigKey)
if err != nil {
return ListingPublishAgreementsDTO{}, err
}
agreements := DefaultListingPublishAgreements()
if err := json.Unmarshal([]byte(value), &agreements); err != nil {
agreements = DefaultListingPublishAgreements()
}
normalizeListingPublishAgreements(&agreements)
return agreements, nil
}
func (s *Service) postRentalNotice(ctx context.Context) (PostRentalNoticeDTO, error) {
value, err := s.repo.FindValue(ctx, postRentalNoticeConfigKey)
if err != nil {
return PostRentalNoticeDTO{}, err
}
notice := DefaultPostRentalNotice()
if err := json.Unmarshal([]byte(value), &notice); err != nil {
notice = DefaultPostRentalNotice()
}
normalizePostRentalNotice(&notice)
return notice, nil
}
@@ -0,0 +1,175 @@
package systemconfig
import (
"context"
"encoding/json"
"strconv"
"strings"
"hfb_sys/backend/internal/model"
)
const homeEntriesEnabledConfigKey = "mobile.home_entries_enabled"
func defaultHomeEntriesEnabledConfigValue() string {
// 默认展示,保持引入总开关前的线上表现;需要隐藏时由后台开关显式关闭。
return "true"
}
func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
items, err := s.homeAnnouncements(ctx)
if err != nil {
return nil, err
}
return &HomeAnnouncementsDTO{Items: items}, nil
}
func (s *Service) HomeConfig(ctx context.Context, siteCode string) (*HomeConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
announcements, err := s.homeAnnouncements(ctx)
if err != nil {
return nil, err
}
banners, err := s.homeBanners(ctx)
if err != nil {
return nil, err
}
awRecycle, err := s.awRecycle(ctx)
if err != nil {
return nil, err
}
cooperationFeedback, err := s.cooperationFeedback(ctx)
if err != nil {
return nil, err
}
publishOptions, err := s.publishOptions(ctx)
if err != nil {
return nil, err
}
homeEntriesEnabled, err := s.homeEntriesEnabled(ctx)
if err != nil {
return nil, err
}
config := &HomeConfigDTO{
Announcements: announcements,
Banners: banners,
PublishOptions: publishOptions,
AWRecycle: awRecycle,
CooperationFeedback: cooperationFeedback,
HomeEntriesEnabled: homeEntriesEnabled,
}
s.applySiteHomeOverrides(ctx, siteCode, config)
return config, nil
}
// applySiteHomeOverrides 用分站自己的首页入口覆盖主站配置。字段为空表示继续继承主站。
func (s *Service) applySiteHomeOverrides(ctx context.Context, siteCode string, config *HomeConfigDTO) {
siteCode = strings.TrimSpace(siteCode)
if s == nil || s.repo == nil || s.repo.db == nil || config == nil || siteCode == "" {
return
}
var site model.DistributionSite
err := s.repo.db.WithContext(ctx).
Select("aw_recycle_json", "cooperation_feedback_json").
Where("site_code = ? AND status = ?", siteCode, "active").
First(&site).Error
if err != nil {
return
}
if item, ok := parseAWRecycleOverride(site.AWRecycleJSON); ok {
config.AWRecycle = item
}
if item, ok := parseCooperationFeedbackOverride(site.CooperationFeedbackJSON); ok {
config.CooperationFeedback = item
}
}
func parseAWRecycleOverride(raw string) (AWRecycleConfig, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return AWRecycleConfig{}, false
}
var item AWRecycleConfig
if err := json.Unmarshal([]byte(raw), &item); err != nil {
return AWRecycleConfig{}, false
}
return normalizeAWRecycle(item), true
}
func parseCooperationFeedbackOverride(raw string) (CooperationFeedbackConfig, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return CooperationFeedbackConfig{}, false
}
var item CooperationFeedbackConfig
if err := json.Unmarshal([]byte(raw), &item); err != nil {
return CooperationFeedbackConfig{}, false
}
return normalizeCooperationFeedback(item), true
}
func (s *Service) homeAnnouncements(ctx context.Context) ([]string, error) {
value, err := s.repo.FindValue(ctx, homeAnnouncementsConfigKey)
if err != nil {
return nil, err
}
items := defaultHomeAnnouncements()
if err := json.Unmarshal([]byte(value), &items); err != nil {
items = defaultHomeAnnouncements()
}
return normalizeAnnouncements(items), nil
}
func (s *Service) homeBanners(ctx context.Context) ([]HomeBannerItem, error) {
value, err := s.repo.FindValue(ctx, homeBannersConfigKey)
if err != nil {
return nil, err
}
items := defaultHomeBanners()
if err := json.Unmarshal([]byte(value), &items); err != nil {
items = defaultHomeBanners()
}
return normalizeHomeBanners(items), nil
}
func (s *Service) awRecycle(ctx context.Context) (AWRecycleConfig, error) {
value, err := s.repo.FindValue(ctx, awRecycleConfigKey)
if err != nil {
return defaultAWRecycle(), err
}
item := defaultAWRecycle()
if err := json.Unmarshal([]byte(value), &item); err != nil {
item = defaultAWRecycle()
}
return normalizeAWRecycle(item), nil
}
func (s *Service) cooperationFeedback(ctx context.Context) (CooperationFeedbackConfig, error) {
value, err := s.repo.FindValue(ctx, cooperationFeedbackConfigKey)
if err != nil {
return defaultCooperationFeedback(), err
}
item := defaultCooperationFeedback()
if err := json.Unmarshal([]byte(value), &item); err != nil {
item = defaultCooperationFeedback()
}
return normalizeCooperationFeedback(item), nil
}
func (s *Service) homeEntriesEnabled(ctx context.Context) (bool, error) {
value, err := s.repo.FindValue(ctx, homeEntriesEnabledConfigKey)
if err != nil {
return false, err
}
return parseHomeEntriesEnabled(value), nil
}
func parseHomeEntriesEnabled(value string) bool {
enabled, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && enabled
}
@@ -0,0 +1,81 @@
package systemconfig
import (
"context"
"strconv"
"strings"
"hfb_sys/backend/internal/modules/rentergrowth"
smsnode "hfb_sys/backend/internal/modules/sms"
)
func (s *Service) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
// 备份定时只能通过独立接口修改,避免拥有普通系统配置权限的管理员改变备份策略。
if strings.HasPrefix(key, "backup.") {
return nil, ErrInvalidConfig
}
if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") {
return nil, ErrInvalidConfig
}
if key == "listing.publish_cooldown_minutes" {
minutes, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || minutes < 0 {
return nil, ErrInvalidConfig
}
}
if key == publishOptionsConfigKey {
value, err := normalizePublishOptionsValue(req.Value)
if err != nil {
return nil, ErrInvalidConfig
}
req.Value = value
}
if key == rentergrowth.ConfigKey {
cfg, err := rentergrowth.ParseConfigValue(req.Value)
if err != nil {
return nil, ErrInvalidConfig
}
value, err := rentergrowth.MarshalConfig(cfg)
if err != nil {
return nil, ErrInvalidConfig
}
req.Value = value
}
if key == smsnode.ConfigKey {
cfg, err := smsnode.ParseConfigValue(req.Value)
if err != nil {
return nil, ErrInvalidConfig
}
value, err := smsnode.MarshalConfig(cfg)
if err != nil {
return nil, ErrInvalidConfig
}
req.Value = value
}
return s.repo.Update(ctx, actorID, key, req, meta)
}
func (s *Service) UpdateBackupSchedule(ctx context.Context, actorID uint64, req BackupScheduleDTO, meta AuditMeta) (*BackupScheduleDTO, error) {
if s.repo == nil || req.FullHour < 0 || req.FullHour > 23 || req.FullMinute < 0 || req.FullMinute > 59 || req.BinlogIntervalMinutes < 1 || req.BinlogIntervalMinutes > 5 {
return nil, ErrInvalidConfig
}
updates := []struct {
key string
value string
description string
}{
{"backup.schedule_enabled", strconv.FormatBool(req.Enabled), "在线备份定时任务是否启用"},
{"backup.full_hour", strconv.Itoa(req.FullHour), "每日全量备份小时(Asia/Shanghai)"},
{"backup.full_minute", strconv.Itoa(req.FullMinute), "每日全量备份分钟(Asia/Shanghai)"},
{"backup.binlog_interval_minutes", strconv.Itoa(req.BinlogIntervalMinutes), "binlog 归档间隔分钟(1-5)"},
}
for _, item := range updates {
if _, err := s.repo.Update(ctx, actorID, item.key, UpdateRequest{Value: item.value, Description: item.description}, meta); err != nil {
return nil, err
}
}
return &req, nil
}