feat: 接入旧站管理端并完整迁移后台能力

This commit is contained in:
yml
2026-11-12 22:03:01 +08:00
parent e5f923706b
commit a4441bba9a
509 changed files with 140714 additions and 1068 deletions
+10 -3
View File
@@ -121,7 +121,9 @@ type HomeBannerHotspot struct {
type PublishOptionsDTO struct {
// SchemaVersion 发布选项结构版本;缺省 0 代表未版本化的历史配置。
// UnlockDemulanEnabled 发布页是否展示「是否解锁德穆兰」选项。
SchemaVersion int `json:"schema_version,omitempty"`
UnlockDemulanEnabled bool `json:"unlock_demulan_enabled"`
NextSeasonPassEnabled bool `json:"next_season_pass_enabled"`
ServerOptions []string `json:"server_options"`
FaceOptions []string `json:"face_options"`
@@ -166,6 +168,9 @@ type PublishScreenshotSlot struct {
type PublishPriceConfig struct {
DepositPlaceholder string `json:"deposit_placeholder"`
DepositHint string `json:"deposit_hint"`
DepositTooltip string `json:"deposit_tooltip"`
PremiumZoneTooltip string `json:"premium_zone_tooltip"`
CompensationNote string `json:"compensation_note"`
PricePlaceholder string `json:"price_placeholder"`
RatioDescription string `json:"ratio_description"`
}
@@ -186,9 +191,11 @@ type PublishDepositSkinGroupRule struct {
}
type PublishRatioConfig struct {
InsuranceBaseRatios []PublishInsuranceBaseRatio `json:"insurance_base_ratios"`
ConfigItems []PublishRatioConfigItem `json:"config_items"`
CoinCorrections []PublishCoinCorrection `json:"coin_corrections"`
QualityOwnerRatioDiscount float64 `json:"quality_owner_ratio_discount"`
PremiumReviewRatioMarkup float64 `json:"premium_review_ratio_markup"`
InsuranceBaseRatios []PublishInsuranceBaseRatio `json:"insurance_base_ratios"`
ConfigItems []PublishRatioConfigItem `json:"config_items"`
CoinCorrections []PublishCoinCorrection `json:"coin_corrections"`
}
type PublishInsuranceBaseRatio struct {
@@ -9,6 +9,21 @@ import (
const defaultBannerHotspotHoverScale = 1.06
const maxBannerHotspotHoverScale = 1.5
func migrateInsuranceBillingCopy(value string) string {
value = strings.Replace(
value,
"注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:",
"无忧保险服务费按实际纯币结算金额计算,额外物品不计入计费基数;具体费率以当前配置为准。",
1,
)
return strings.Replace(
value,
"并从卖家实际到手租金中扣除。",
"并按实际纯币结算金额计算,额外物品不计入保险基数。",
1,
)
}
func normalizeAnnouncements(items []string) []string {
normalized := make([]string, 0, len(items))
for _, item := range items {
@@ -231,6 +246,21 @@ func normalizePublishOptions(options *PublishOptionsDTO) {
if options.PriceConfig.DepositHint == "" {
options.PriceConfig.DepositHint = defaults.PriceConfig.DepositHint
}
if options.PriceConfig.DepositTooltip == "" {
options.PriceConfig.DepositTooltip = defaults.PriceConfig.DepositTooltip
}
if options.PriceConfig.PremiumZoneTooltip == "" {
options.PriceConfig.PremiumZoneTooltip = defaults.PriceConfig.PremiumZoneTooltip
} else {
options.PriceConfig.PremiumZoneTooltip =
migrateInsuranceBillingCopy(options.PriceConfig.PremiumZoneTooltip)
}
if options.PriceConfig.CompensationNote == "" {
options.PriceConfig.CompensationNote = defaults.PriceConfig.CompensationNote
} else {
options.PriceConfig.CompensationNote =
migrateInsuranceBillingCopy(options.PriceConfig.CompensationNote)
}
if options.PriceConfig.PricePlaceholder == "" {
options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder
}
@@ -252,6 +282,19 @@ func normalizePublishOptions(options *PublishOptionsDTO) {
if len(options.RatioConfig.CoinCorrections) == 0 {
options.RatioConfig.CoinCorrections = defaults.RatioConfig.CoinCorrections
}
options.RatioConfig.PremiumReviewRatioMarkup = normalizePremiumReviewRatioMarkup(
options.RatioConfig.PremiumReviewRatioMarkup,
)
}
func normalizePremiumReviewRatioMarkup(value float64) float64 {
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
if value > 10 {
return 10
}
return math.Round(value*10) / 10
}
func normalizeSalePriceConfig(config *PublishSalePriceConfig) {
@@ -3,8 +3,10 @@ package systemconfig
import "encoding/json"
const (
// publishOptionsSchemaVersion 发布选项结构版本;v2 新增「是否解锁德穆兰」开关(默认关闭),
// 并把「下赛季是否购买通行证」默认打开。
publishOptionsSchemaVersion = 2
publishOptionsConfigKey = "listing.publish_options"
publishOptionsSchemaVersion = 1
)
func defaultPublishOptionsConfigValue() string {
@@ -18,7 +20,8 @@ func defaultPublishOptionsConfigValue() string {
func DefaultPublishOptions() PublishOptionsDTO {
return PublishOptionsDTO{
SchemaVersion: publishOptionsSchemaVersion,
NextSeasonPassEnabled: false,
UnlockDemulanEnabled: false,
NextSeasonPassEnabled: true,
ServerOptions: []string{"QQ", "微信", "Steam"},
FaceOptions: []string{"是", "否"},
RankOptions: []string{"青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "三角洲巅峰"},
@@ -90,8 +93,38 @@ func DefaultPublishOptions() PublishOptionsDTO {
PriceConfig: PublishPriceConfig{
DepositPlaceholder: "温馨提示:本押金用于保障账号安全。若买家在租用期间,使用非纯币类道具/资源(如消耗品、材料等)或导致账号被封禁,相关损失将直接从押金中扣除进行赔付。押金过高会延长出租等待时间(请自行衡量)感谢理解。",
DepositHint: "租客缴纳,封号必赔付",
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
DepositTooltip: `1. 系统推荐押金(基础覆盖)
系统将根据您填写的出租皮肤、特勤处等级等信息,自动计算并生成一个推荐押金额度。该额度已覆盖上述主要资产的合理市场价值,可作为押金设置的基础参考。
2. 系统推荐押金未覆盖项目(需自行评估)
请注意,系统推荐押金暂不包含以下资产类型,您需要根据实际情况额外评估其价值:
- 收藏室内的稀有物品
- 其他具有个人纪念意义或情怀价值的虚拟资产(如限定称号、绝版道具等)
3. 未出租价值折算规则(重点)
请在「收藏室及不出租部分哈弗币价值」中填写收藏室等不出租资产折合的哈弗币。系统按 1元=50万 计算参考值,并自动更新推荐押金:低于参考值时覆盖为参考值,否则追加参考值的 50%。
示例:基础推荐押金 200 元、收藏室价值 200M → 参考值 400 元,200<400,推荐押金更新为 400 元。
目的:该举能够有效覆盖租客可能出现的洗号、违规操作导致账号封禁等风险带来的损失,为您提供充分的风险缓冲。
4. 额外保障选项
若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`,
PremiumZoneTooltip: `极品专区是面向优质号主和高价值账号的专属展示区。
1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。
2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。
3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。
4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并按实际纯币结算金额计算,额外物品不计入保险基数。
5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。`,
CompensationNote: "普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。无忧保险服务费按实际纯币结算金额计算,额外物品不计入计费基数;具体费率以当前配置为准。押金须达到推荐押金方可购买。",
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
},
DepositRecommend: PublishDepositRecommend{
BaseAmount: 50,
@@ -102,6 +135,8 @@ func DefaultPublishOptions() PublishOptionsDTO {
},
},
RatioConfig: PublishRatioConfig{
QualityOwnerRatioDiscount: 1,
PremiumReviewRatioMarkup: 1,
InsuranceBaseRatios: []PublishInsuranceBaseRatio{
{Insurance: "3*3", Ratio: 40},
{Insurance: "2*3", Ratio: 42},
@@ -106,6 +106,200 @@ func TestNormalizePublishOptionsValuePreservesCurrentSchema(t *testing.T) {
}
}
func TestNormalizePublishOptionsValueDefaultsQualityOwnerRatioDiscount(t *testing.T) {
legacy := `{
"schema_version": 2,
"ratio_config": {
"insurance_base_ratios": [{"insurance": "3*3", "ratio": 40}]
}
}`
normalized, err := normalizePublishOptionsValue(legacy)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var got PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &got); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if got.RatioConfig.QualityOwnerRatioDiscount != 1 {
t.Fatalf(
"quality_owner_ratio_discount = %v, want 1",
got.RatioConfig.QualityOwnerRatioDiscount,
)
}
if got.RatioConfig.PremiumReviewRatioMarkup != 1 {
t.Fatalf(
"premium_review_ratio_markup = %v, want 1",
got.RatioConfig.PremiumReviewRatioMarkup,
)
}
}
func TestNormalizePublishOptionsValueDefaultsDepositTooltip(t *testing.T) {
normalized, err := normalizePublishOptionsValue(`{"schema_version":2}`)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &options); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !strings.Contains(options.PriceConfig.DepositTooltip, "系统推荐押金(基础覆盖)") {
t.Fatalf("押金说明默认文案缺失: %q", options.PriceConfig.DepositTooltip)
}
}
func TestNormalizePublishOptionsValueDefaultsCompensationNote(t *testing.T) {
normalized, err := normalizePublishOptionsValue(`{"schema_version":2}`)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &options); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !strings.Contains(options.PriceConfig.CompensationNote, "普通赔付方案如下") {
t.Fatalf("赔付方案说明默认文案缺失: %q", options.PriceConfig.CompensationNote)
}
}
func TestNormalizePublishOptionsValueDefaultsPremiumZoneTooltip(t *testing.T) {
normalized, err := normalizePublishOptionsValue(`{"schema_version":2}`)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &options); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !strings.Contains(options.PriceConfig.PremiumZoneTooltip, "极品专区是面向优质号主") {
t.Fatalf("极品专区说明默认文案缺失: %q", options.PriceConfig.PremiumZoneTooltip)
}
}
func TestNormalizePublishOptionsValueMigratesInsuranceBillingBaseCopy(t *testing.T) {
options := DefaultPublishOptions()
options.PriceConfig.CompensationNote = "注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金以上方可购买。"
options.PriceConfig.PremiumZoneTooltip = "4. 保险服务:服务费率按当前配置执行,并从卖家实际到手租金中扣除。"
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 !strings.Contains(got.PriceConfig.CompensationNote, "按实际纯币结算金额计算") {
t.Fatalf("赔付说明未迁移纯币计费口径: %q", got.PriceConfig.CompensationNote)
}
if strings.Contains(got.PriceConfig.CompensationNote, "订单佣金") {
t.Fatalf("赔付说明仍含旧计费口径: %q", got.PriceConfig.CompensationNote)
}
if !strings.Contains(got.PriceConfig.PremiumZoneTooltip, "额外物品不计入保险基数") {
t.Fatalf("专区说明未迁移纯币计费口径: %q", got.PriceConfig.PremiumZoneTooltip)
}
}
func TestNormalizePublishOptionsValuePreservesExplicitZeroPremiumReviewRatioMarkup(t *testing.T) {
options := DefaultPublishOptions()
options.RatioConfig.PremiumReviewRatioMarkup = 0
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 got.RatioConfig.PremiumReviewRatioMarkup != 0 {
t.Fatalf(
"premium_review_ratio_markup = %v, want explicit 0",
got.RatioConfig.PremiumReviewRatioMarkup,
)
}
}
func TestNormalizePublishOptionsValuePreservesExplicitZeroQualityOwnerDiscount(t *testing.T) {
options := DefaultPublishOptions()
options.RatioConfig.QualityOwnerRatioDiscount = 0
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 got.RatioConfig.QualityOwnerRatioDiscount != 0 {
t.Fatalf(
"quality_owner_ratio_discount = %v, want explicit 0",
got.RatioConfig.QualityOwnerRatioDiscount,
)
}
}
func TestNormalizePublishOptionsValueMigratesSchemaV1Switches(t *testing.T) {
legacy := `{"schema_version":1,"next_season_pass_enabled":false,"server_options":["QQ"]}`
normalized, err := normalizePublishOptionsValue(legacy)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var got PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &got); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if got.SchemaVersion != publishOptionsSchemaVersion {
t.Fatalf("schema_version = %d, want %d", got.SchemaVersion, publishOptionsSchemaVersion)
}
if got.UnlockDemulanEnabled {
t.Fatal("v1 升级后「是否解锁德穆兰」应默认关闭")
}
if !got.NextSeasonPassEnabled {
t.Fatal("v1 升级后「下赛季是否购买通行证」应默认打开")
}
}
func TestNormalizePublishOptionsValueKeepsSchemaV2Switches(t *testing.T) {
options := DefaultPublishOptions()
options.UnlockDemulanEnabled = true
options.NextSeasonPassEnabled = false
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 !got.UnlockDemulanEnabled {
t.Fatal("v2 配置中后台手动开启的德穆兰开关不应被覆盖")
}
if got.NextSeasonPassEnabled {
t.Fatal("v2 配置中后台手动关闭的通行证开关不应被覆盖")
}
}
func TestNormalizePublishOptionsValueRepairsMojibake(t *testing.T) {
options := DefaultPublishOptions()
raw, err := json.Marshal(options)
@@ -10,6 +10,7 @@ import (
"hfb_jysh/backend/internal/auditlog"
"hfb_jysh/backend/internal/model"
"hfb_jysh/backend/internal/modules/qualityowner"
"hfb_jysh/backend/internal/modules/rentergrowth"
smsnode "hfb_jysh/backend/internal/modules/sms"
@@ -39,6 +40,7 @@ var defaultConfigs = []defaultConfig{
{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: qualityowner.ConfigKey, Value: qualityowner.DefaultConfigValue(), Description: "优质号主保证金、开通协议与无忧租号保险费率配置 JSON"},
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
@@ -89,6 +91,7 @@ var adminVisibleConfigKeys = []string{
"order.pending_payment_timeout_minutes",
"order.return_overdue_grace_minutes",
"profile.post_rental_notice",
qualityowner.ConfigKey,
"renter.growth_level_rules",
"sms.business_templates",
}
@@ -129,6 +132,12 @@ func (r *Repository) List(ctx context.Context) ([]ConfigDTO, error) {
}
items := make([]ConfigDTO, 0, len(rows))
for _, row := range rows {
if row.Key == qualityowner.ConfigKey {
rules, err := qualityowner.ParseConfigValue(row.Value)
if err == nil {
row.Value, _ = qualityowner.MarshalConfig(rules)
}
}
items = append(items, toDTO(row))
}
return items, nil
@@ -328,6 +337,15 @@ func normalizePublishOptionsValue(value string) (string, error) {
if err := json.Unmarshal([]byte(value), &fields); err != nil {
return "", err
}
if !hasNestedJSONField(fields, "ratio_config", "quality_owner_ratio_discount") {
options.RatioConfig.QualityOwnerRatioDiscount =
DefaultPublishOptions().RatioConfig.QualityOwnerRatioDiscount
}
if !hasNestedJSONField(fields, "ratio_config", "premium_review_ratio_markup") {
options.RatioConfig.PremiumReviewRatioMarkup =
DefaultPublishOptions().RatioConfig.PremiumReviewRatioMarkup
}
originalVersion := options.SchemaVersion
if options.SchemaVersion < publishOptionsSchemaVersion {
if hasLegacyPublishQuantityItem(options.QuantityItems) {
options = DefaultPublishOptions()
@@ -340,6 +358,12 @@ func normalizePublishOptionsValue(value string) (string, error) {
} else {
normalizePublishOptions(&options)
}
// v2 一次性迁移:新增「是否解锁德穆兰」开关并默认关闭,同时打开「下赛季是否购买通行证」。
// 已经是 v2 的配置不再覆盖,保留后台手动调整的结果。
if originalVersion < 2 {
options.UnlockDemulanEnabled = false
options.NextSeasonPassEnabled = true
}
options.SchemaVersion = publishOptionsSchemaVersion
raw, err := json.Marshal(options)
if err != nil {
@@ -348,6 +372,19 @@ func normalizePublishOptionsValue(value string) (string, error) {
return string(raw), nil
}
func hasNestedJSONField(fields map[string]json.RawMessage, parent, child string) bool {
raw, ok := fields[parent]
if !ok {
return false
}
var nested map[string]json.RawMessage
if err := json.Unmarshal(raw, &nested); err != nil {
return false
}
_, ok = nested[child]
return ok
}
func hasLegacyPublishQuantityItem(items []PublishQuantityItem) bool {
for _, item := range items {
if item.Key == "kit5" {
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"hfb_jysh/backend/internal/modules/qualityowner"
"hfb_jysh/backend/internal/modules/rentergrowth"
smsnode "hfb_jysh/backend/internal/modules/sms"
)
@@ -55,6 +56,17 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up
}
req.Value = value
}
if key == qualityowner.ConfigKey {
cfg, err := qualityowner.ParseConfigValue(req.Value)
if err != nil {
return nil, ErrInvalidConfig
}
value, err := qualityowner.MarshalConfig(cfg)
if err != nil {
return nil, ErrInvalidConfig
}
req.Value = value
}
return s.repo.Update(ctx, actorID, key, req, meta)
}