fix(systemconfig): 首页入口展示开关与发布选项编码修复

This commit is contained in:
yml2213
2026-10-22 20:25:57 +08:00
parent 51855a0e17
commit 2ce3320d5f
16 changed files with 591 additions and 54 deletions
@@ -35,6 +35,8 @@ type HomeConfigDTO struct {
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炫彩回收」入口:点击展示可配置图片。
@@ -118,6 +120,8 @@ type HomeBannerHotspot struct {
}
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"`
@@ -0,0 +1,24 @@
package systemconfig
import "testing"
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)
}
}
}
@@ -2,7 +2,10 @@ package systemconfig
import "encoding/json"
const publishOptionsConfigKey = "listing.publish_options"
const (
publishOptionsConfigKey = "listing.publish_options"
publishOptionsSchemaVersion = 1
)
func defaultPublishOptionsConfigValue() string {
raw, err := json.Marshal(DefaultPublishOptions())
@@ -14,6 +17,7 @@ func defaultPublishOptionsConfigValue() string {
func DefaultPublishOptions() PublishOptionsDTO {
return PublishOptionsDTO{
SchemaVersion: publishOptionsSchemaVersion,
NextSeasonPassEnabled: false,
ServerOptions: []string{"QQ", "微信", "Steam"},
FaceOptions: []string{"是", "否"},
@@ -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
}
@@ -6,6 +6,7 @@ import (
"errors"
"strings"
"sync"
"unicode/utf8"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
@@ -54,6 +55,7 @@ var defaultConfigs = []defaultConfig{
{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)"},
@@ -84,6 +86,7 @@ var adminVisibleConfigKeys = []string{
"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",
@@ -288,30 +291,14 @@ func updateLegacyPublishOptions(tx *gorm.DB, item defaultConfig) error {
if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil {
return err
}
if isLegacyPublishOptionsValue(row.Value) {
row.Value = item.Value
row.Description = item.Description
return tx.Save(&row).Error
}
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(row.Value), &options); err != nil {
row.Value = item.Value
row.Description = item.Description
return tx.Save(&row).Error
}
before := row.Value
isOldPublishOptionsSchema := !strings.Contains(before, `"ban_record_options"`)
normalizePublishOptions(&options)
if isOldPublishOptionsSchema {
applyConditionalBanEvidenceDefault(&options)
}
raw, err := json.Marshal(options)
normalized, err := normalizePublishOptionsValue(row.Value)
if err != nil {
return err
normalized = item.Value
}
row.Value = string(raw)
changed := row.Value != normalized || row.Description != item.Description
row.Value = normalized
row.Description = item.Description
if row.Value == before {
if !changed {
return nil
}
return tx.Save(&row).Error
@@ -333,41 +320,164 @@ func applyConditionalBanEvidenceDefault(options *PublishOptionsDTO) {
}
}
func isLegacyPublishOptionsValue(value string) bool {
legacyMarkers := []string{
`"kit5"`,
"5级全装套",
"6级子弹",
"6头数量",
"6甲数量",
"0.26元/发",
"0.8元/发",
"çº",
"å¼",
"å…",
"å¤",
"ä½",
"é«",
"ï¼",
func normalizePublishOptionsValue(value string) (string, error) {
value = repairMojibakeJSON(value)
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(value), &options); err != nil {
return "", err
}
for _, marker := range legacyMarkers {
if strings.Contains(value, marker) {
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
}
func isMojibakeText(value string) bool {
mojibakeMarkers := []string{"å", "æ", "ç", "è", "é", "ä", "ï¼", "ã€", "â"}
for _, marker := range mojibakeMarkers {
if strings.Contains(value, marker) {
// 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",
@@ -3,11 +3,18 @@ package systemconfig
import (
"context"
"encoding/json"
"strconv"
"strings"
"hfb_sys/backend/internal/model"
)
const homeEntriesEnabledConfigKey = "mobile.home_entries_enabled"
func defaultHomeEntriesEnabledConfigValue() string {
return "false"
}
func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
@@ -43,12 +50,17 @@ func (s *Service) HomeConfig(ctx context.Context, siteCode string) (*HomeConfigD
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
@@ -147,3 +159,16 @@ func (s *Service) cooperationFeedback(ctx context.Context) (CooperationFeedbackC
}
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
}
@@ -26,6 +26,13 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up
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 {