Compare commits
3
Commits
6a99c1121b
...
5840b7da40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5840b7da40 | ||
|
|
d21ddd9e96 | ||
|
|
cb8aceb433 |
@@ -22,6 +22,16 @@ func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAnyPermission 检查当前管理员是否拥有任意一个指定的权限。
|
||||
// 超级管理员(拥有 super_admin 角色或 * 权限)自动放行。
|
||||
func RequireAnyPermission(rdb *redis.Client, permCodes ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if checkAnyPermission(c, rdb, permCodes...) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if predicate == nil || !predicate(c) {
|
||||
@@ -34,7 +44,7 @@ func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin
|
||||
}
|
||||
}
|
||||
|
||||
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
func checkAnyPermission(c *gin.Context, rdb *redis.Client, permCodes ...string) bool {
|
||||
value, ok := c.Get(ContextAdminID)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
@@ -56,9 +66,14 @@ func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
}
|
||||
|
||||
for _, code := range codes {
|
||||
if code == permCode || code == "*" {
|
||||
if code == "*" {
|
||||
return true
|
||||
}
|
||||
for _, target := range permCodes {
|
||||
if code == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
|
||||
@@ -66,6 +81,10 @@ func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
return checkAnyPermission(c, rdb, permCode)
|
||||
}
|
||||
|
||||
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
|
||||
if rdb == nil {
|
||||
return nil, errors.New("redis unavailable")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestRequireAnyPermission(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "127.0.0.1:16379",
|
||||
DB: 14,
|
||||
})
|
||||
ctx := t.Context()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
t.Skipf("跳过测试:未检测到测试 Redis: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = rdb.FlushDB(context.Background()).Err()
|
||||
_ = rdb.Close()
|
||||
})
|
||||
_ = rdb.FlushDB(ctx)
|
||||
|
||||
adminID := uint64(101)
|
||||
permKey := fmt.Sprintf("admin:perms:%d", adminID)
|
||||
|
||||
setPerms := func(perms []string) {
|
||||
raw, err := json.Marshal(perms)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rdb.Set(ctx, permKey, string(raw), time.Minute).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
setupRouter := func() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set(ContextAdminID, adminID)
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/test-any", RequireAnyPermission(rdb, "distribution:site_view", "qrcode:view"), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
router := setupRouter()
|
||||
|
||||
// 1. 无任何权限 -> 403
|
||||
setPerms([]string{"dashboard:view"})
|
||||
req := httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 2. 具备 qrcode:view -> 200
|
||||
setPerms([]string{"qrcode:view"})
|
||||
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 3. 具备 distribution:site_view -> 200
|
||||
setPerms([]string{"distribution:site_view"})
|
||||
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 4. 超管通配符 * -> 200
|
||||
setPerms([]string{"*"})
|
||||
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,33 @@ func (r *Repository) hasGlobalDistributionAccessWithDB(ctx context.Context, db *
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) hasPermission(ctx context.Context, adminID uint64, permCode string) (bool, error) {
|
||||
if adminID == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if r == nil || r.db == nil {
|
||||
return false, nil
|
||||
}
|
||||
var n int64
|
||||
if err := r.db.WithContext(ctx).Table("roles").
|
||||
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
|
||||
Where("aur.admin_user_id = ? AND roles.code = ?", adminID, "super_admin").
|
||||
Count(&n).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n > 0 {
|
||||
return true, nil
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Table("permissions").
|
||||
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
|
||||
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
|
||||
Where("aur.admin_user_id = ? AND permissions.code = ?", adminID, permCode).
|
||||
Count(&n).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) listActiveMemberSiteIDs(ctx context.Context, adminID uint64) ([]uint64, error) {
|
||||
var members []model.DistributionSiteMember
|
||||
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
|
||||
|
||||
@@ -359,6 +359,41 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
|
||||
if err != nil || len(superSites) != 2 {
|
||||
t.Fatalf("superadmin should list all sites, got %d err=%v", len(superSites), err)
|
||||
}
|
||||
|
||||
qrView := model.Permission{Code: "qrcode:view", Name: "查看二维码池", Resource: "qrcode", Action: "view"}
|
||||
if err := db.Create(&qrView).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csRole := model.Role{Code: "customer_service_role", Name: "客服"}
|
||||
if err := db.Create(&csRole).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.RolePermission{RoleID: csRole.ID, PermissionID: qrView.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.AdminUserRole{AdminID: 66, RoleID: csRole.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csSites, err := svc.ListSitesForAdmin(t.Context(), 66)
|
||||
if err != nil || len(csSites) != 2 {
|
||||
t.Fatalf("cs admin with qrcode:view should list all sites for qr pool, got %d err=%v", len(csSites), err)
|
||||
}
|
||||
|
||||
// 码池维护只需要读站点列表,必须止步于此:客服不得因此获得跨站写权限。
|
||||
// distribution:all_view 是跨站写操作(改站/删站/重置站长密码)的总开关,
|
||||
// 而它挂在被分站站长共用的 ops 角色上,一旦授予即击穿多租户隔离。
|
||||
if global, err := repo.hasGlobalDistributionAccess(t.Context(), 66); err != nil || global {
|
||||
t.Fatalf("qrcode admin must stay non-global, global=%v err=%v", global, err)
|
||||
}
|
||||
if _, err := repo.ScopeSiteID(t.Context(), 66, other.ID); err != ErrSiteAccessDenied {
|
||||
t.Fatalf("qrcode admin must not scope into other site, err=%v", err)
|
||||
}
|
||||
if err := repo.ResetSiteAdminPassword(t.Context(), 66, other.ID, "whatever123"); err != ErrSiteAccessDenied {
|
||||
t.Fatalf("qrcode admin must not reset other site admin password, err=%v", err)
|
||||
}
|
||||
if err := repo.DeleteSite(t.Context(), 66, other.ID); err != ErrSiteAccessDenied {
|
||||
t.Fatalf("qrcode admin must not delete other site, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
|
||||
@@ -1787,4 +1822,3 @@ func TestListOrdersPerformanceAndScoping(t *testing.T) {
|
||||
t.Fatalf("expected 1 receive order for site2, got %d", recvPage.Total)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,18 @@ func (s *Service) ListSitesForAdmin(ctx context.Context, adminID uint64) ([]Site
|
||||
if global {
|
||||
return items, nil
|
||||
}
|
||||
// 客服(qrcode:view)是平台级可信角色,统一维护各分站二维码池。
|
||||
// 二维码池接口本身只校验 qrcode:* 权限、不按站点归属收窄,因此这里直接放行全量站点,
|
||||
// 让码池页面能拿到站点 id;站长/分销商等站点成员仍在下方按其绑定站点收窄。
|
||||
// 注意:不要为了这个需求给共享角色(如 ops,同时是分站站长)授予 distribution:all_view——
|
||||
// 该权限是跨站写操作的总开关(改站/删站/重置站长密码),会击穿多租户隔离。
|
||||
canViewQr, err := s.repo.hasPermission(ctx, adminID, "qrcode:view")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if canViewQr {
|
||||
return items, nil
|
||||
}
|
||||
siteIDs, err := s.repo.listActiveMemberSiteIDs(ctx, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -238,6 +238,15 @@ type ExternalUploadInventory struct {
|
||||
Level6Helmets int `json:"level6Helmets"`
|
||||
Level6Armor int `json:"level6Armor"`
|
||||
Skins []string `json:"skins"`
|
||||
|
||||
// 以下字段名与 publish_options.quantity_items 的 key 对齐:
|
||||
// 后台新增消耗品时,只要按同样的 key 在此登记即可被上传链路识别并计入价格。
|
||||
Level6Ammo int `json:"level6Ammo"`
|
||||
BarrettAmmo int `json:"barrettAmmo"`
|
||||
GridCard9 int `json:"gridCard9"`
|
||||
RedPacket45 int `json:"redPacket45"`
|
||||
CoffeeBean int `json:"coffeeBean"`
|
||||
BulletPart int `json:"bulletPart"`
|
||||
}
|
||||
|
||||
type ExternalUploadMeta struct {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -56,7 +57,9 @@ func (s *Service) AdjustExternalListing(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.AdjustExternalListing(ctx, adminID, listingID, req, config, meta)
|
||||
// 消耗品单价同样取自后台实时配置,避免调整后的价格与网站现行单价不一致。
|
||||
quantityItems := s.externalPublishOptions(ctx).QuantityItems
|
||||
return s.repo.AdjustExternalListing(ctx, adminID, listingID, req, config, quantityItems, meta)
|
||||
}
|
||||
|
||||
func (r *Repository) AdjustExternalListing(
|
||||
@@ -65,6 +68,7 @@ func (r *Repository) AdjustExternalListing(
|
||||
listingID uint64,
|
||||
req AdminExternalListingAdjustRequest,
|
||||
config salePriceConfig,
|
||||
quantityItems []systemconfig.PublishQuantityItem,
|
||||
meta AuditMeta,
|
||||
) (*ListingDTO, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@@ -105,7 +109,7 @@ func (r *Repository) AdjustExternalListing(
|
||||
}
|
||||
|
||||
before := externalAdjustmentAuditSnapshot(account, listing, summary, breakdown)
|
||||
adjustment, err := calculateExternalListingAdjustment(req, summary, referenceRatio, config)
|
||||
adjustment, err := calculateExternalListingAdjustment(req, summary, referenceRatio, config, quantityItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -179,6 +183,7 @@ func calculateExternalListingAdjustment(
|
||||
summary map[string]any,
|
||||
referenceRatio float64,
|
||||
config salePriceConfig,
|
||||
quantityItems []systemconfig.PublishQuantityItem,
|
||||
) (externalAdjustmentResult, error) {
|
||||
coinM := float64(req.HafCoinAmount) / 1000000
|
||||
coinWan := float64(req.HafCoinAmount) / 10000
|
||||
@@ -207,7 +212,7 @@ func calculateExternalListingAdjustment(
|
||||
AWMBullets: req.AWMBullets,
|
||||
Level6Helmets: req.Level6Helmets,
|
||||
Level6Armor: req.Level6Armor,
|
||||
})
|
||||
}, quantityItems)
|
||||
resources := mergeExternalCoreResources(summary, coreResources)
|
||||
consumablePrice := consumableValue(map[string]any{"resources": resources})
|
||||
sellerTotalPrice := roundMoney(sellerCoinPrice + consumablePrice)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -26,6 +27,7 @@ func TestCalculateExternalListingAdjustmentByDiscountAmount(t *testing.T) {
|
||||
map[string]any{},
|
||||
40,
|
||||
defaultSalePriceConfig(),
|
||||
systemconfig.DefaultPublishOptions().QuantityItems,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("按降价金额计算失败: %v", err)
|
||||
@@ -52,6 +54,7 @@ func TestCalculateExternalListingAdjustmentBySaleRatio(t *testing.T) {
|
||||
map[string]any{},
|
||||
40,
|
||||
defaultSalePriceConfig(),
|
||||
systemconfig.DefaultPublishOptions().QuantityItems,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("按发布比例计算失败: %v", err)
|
||||
@@ -232,7 +235,7 @@ func newExternalAdjustmentFixture(
|
||||
Level6Helmets: 1,
|
||||
Level6Armor: 1,
|
||||
},
|
||||
}, defaultSalePriceConfig())
|
||||
}, defaultSalePriceConfig(), systemconfig.DefaultPublishOptions())
|
||||
createReq.AssetSummary["resources"] = append(
|
||||
createReq.AssetSummary["resources"].([]any),
|
||||
map[string]any{"key": "giftMed", "label": "赠送医疗物资", "price": "1元/个", "quantity": 4, "mode": "赠送"},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
)
|
||||
|
||||
// 外部上传必须按后台实时配置归类皮肤、按配置单价折算消耗品。
|
||||
// 历史缺陷:皮肤分组与消耗品单价是代码里的快照,运营在后台新增皮肤或改价后,
|
||||
// 上传的商品会被归到 other(详情页显示“其他皮肤”、按分组筛选搜不到)且按旧价折算。
|
||||
func TestExternalAccountToCreateRequestFollowsPublishOptions(t *testing.T) {
|
||||
options := systemconfig.PublishOptionsDTO{
|
||||
SkinGroups: []systemconfig.PublishOptionGroup{
|
||||
{Key: "melee", Title: "近战皮肤", Options: []string{"重塑", "挽歌"}},
|
||||
{Key: "operatorGold", Title: "干员金皮", Options: []string{"骇爪-骇客少女"}},
|
||||
},
|
||||
QuantityItems: []systemconfig.PublishQuantityItem{
|
||||
{Key: "awmAmmo", Label: "AWM子弹", Price: "0.7元/发"},
|
||||
{Key: "barrettAmmo", Label: "巴雷特子弹", Price: "0.3元/发"},
|
||||
{Key: "armor6", Label: "6甲", Price: "2.5元/个"},
|
||||
},
|
||||
}
|
||||
|
||||
req := externalAccountToCreateRequest("客服1", 0, ExternalAccountData{
|
||||
LoginMethod: "QQ扫码",
|
||||
Rank: "黑鹰",
|
||||
SafeSlots: 9,
|
||||
Deposit: 400,
|
||||
Currency: ExternalUploadCurrency{HafuCoin: 100, RecycleRatio: 40, RecycleRent: 250},
|
||||
Inventory: ExternalUploadInventory{
|
||||
AWMBullets: 10,
|
||||
BarrettAmmo: 3,
|
||||
Skins: []string{"重塑", "骇爪-骇客少女", "未知皮肤X"},
|
||||
},
|
||||
}, defaultSalePriceConfig(), options)
|
||||
|
||||
groups, ok := req.AssetSummary["skin_groups"].(map[string][]string)
|
||||
if !ok {
|
||||
t.Fatalf("skin_groups = %#v", req.AssetSummary["skin_groups"])
|
||||
}
|
||||
if got := groups["melee"]; len(got) != 1 || got[0] != "重塑" {
|
||||
t.Fatalf("后台新增的近战皮肤未按配置归类: melee = %#v", got)
|
||||
}
|
||||
if got := groups["operatorGold"]; len(got) != 1 || got[0] != "骇爪-骇客少女" {
|
||||
t.Fatalf("后台新增的干员金皮未按配置归类: operatorGold = %#v", got)
|
||||
}
|
||||
if got := groups["other"]; len(got) != 1 || got[0] != "未知皮肤X" {
|
||||
t.Fatalf("未配置的皮肤应归入 other: other = %#v", got)
|
||||
}
|
||||
|
||||
resources, ok := req.AssetSummary["resources"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("resources = %#v", req.AssetSummary["resources"])
|
||||
}
|
||||
byKey := map[string]map[string]any{}
|
||||
for _, raw := range resources {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("资源条目格式异常: %#v", raw)
|
||||
}
|
||||
key, _ := row["key"].(string)
|
||||
byKey[key] = row
|
||||
}
|
||||
// 未填写的 6甲 不应出现在资源清单里(与网站自身发布流程一致)。
|
||||
if _, exists := byKey["armor6"]; exists {
|
||||
t.Fatalf("数量为 0 的消耗品不应写入资源清单: %#v", byKey["armor6"])
|
||||
}
|
||||
if got, _ := byKey["barrettAmmo"]["quantity"].(int); got != 3 {
|
||||
t.Fatalf("新增消耗品未随上传写入: barrettAmmo = %#v", byKey["barrettAmmo"])
|
||||
}
|
||||
if got, _ := byKey["barrettAmmo"]["label"].(string); got != "巴雷特子弹" {
|
||||
t.Fatalf("资源名称应取自配置: %#v", byKey["barrettAmmo"])
|
||||
}
|
||||
if got, _ := byKey["awmAmmo"]["price"].(string); got != "0.7元/发" {
|
||||
t.Fatalf("资源单价应取自配置: %#v", byKey["awmAmmo"])
|
||||
}
|
||||
|
||||
// AWM 10 发 × 0.7 + 巴雷特 3 发 × 0.3 = 7.9,旧快照价 0.6 会算成 6.9。
|
||||
if got := consumableValue(map[string]any{"resources": resources}); got != 7.9 {
|
||||
t.Fatalf("消耗品折算价 = %v, want 7.9", got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
)
|
||||
|
||||
var externalOnlineTimePattern = regexp.MustCompile(`^\D*(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D+(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D*$`)
|
||||
@@ -37,11 +39,13 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 皮肤分组与消耗品单价取自后台实时配置,避免代码里的快照与线上选项脱节。
|
||||
publishOptions := s.externalPublishOptions(ctx)
|
||||
clientUploadTime := parseClientUploadTime(req.UploadTime)
|
||||
results := make([]ExternalUploadResult, 0, len(items))
|
||||
resp := &ExternalUploadResponse{Total: len(items)}
|
||||
for index, item := range items {
|
||||
createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item, salePriceConfig)
|
||||
createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item, salePriceConfig, publishOptions)
|
||||
if err := validateRequest(createReq, rules); err != nil {
|
||||
if len(items) == 1 {
|
||||
return nil, err
|
||||
@@ -244,6 +248,7 @@ func externalAccountToCreateRequest(
|
||||
uploadTime int64,
|
||||
item ExternalAccountData,
|
||||
salePriceConfig salePriceConfig,
|
||||
publishOptions systemconfig.PublishOptionsDTO,
|
||||
) CreateRequest {
|
||||
hafCoinM := item.Currency.HafuCoin
|
||||
pureCoinPrice := item.Currency.RecycleRent
|
||||
@@ -254,7 +259,7 @@ func externalAccountToCreateRequest(
|
||||
skins := cleanStrings(item.Inventory.Skins)
|
||||
remark := strings.TrimSpace(item.Remark)
|
||||
onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime)
|
||||
resources := externalResources(item.Inventory)
|
||||
resources := externalResources(item.Inventory, publishOptions.QuantityItems)
|
||||
consumablePrice := consumableValue(map[string]any{"resources": resources})
|
||||
pricing := calculateExternalPlatformPricing(
|
||||
hafCoinM,
|
||||
@@ -273,7 +278,7 @@ func externalAccountToCreateRequest(
|
||||
"stamina_level": staminaLevel,
|
||||
"load_level": loadLevel,
|
||||
"resources": resources,
|
||||
"skin_groups": externalSkinGroups(skins),
|
||||
"skin_groups": externalSkinGroups(skins, publishOptions.SkinGroups),
|
||||
"online_time_text": onlineTimeText,
|
||||
// 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。
|
||||
"early_morning_response": "否",
|
||||
@@ -420,60 +425,71 @@ func levelText(value int) string {
|
||||
return strconv.Itoa(value) + "级"
|
||||
}
|
||||
|
||||
func externalResources(inventory ExternalUploadInventory) []any {
|
||||
resources := []any{
|
||||
map[string]any{"key": "awmAmmo", "label": "AWM子弹", "price": "0.6元/发", "quantity": inventory.AWMBullets, "mode": "收费"},
|
||||
map[string]any{"key": "helmet6", "label": "6头", "price": "1.5元/个", "quantity": inventory.Level6Helmets, "mode": "收费"},
|
||||
map[string]any{"key": "armor6", "label": "6甲", "price": "2.5元/个", "quantity": inventory.Level6Armor, "mode": "收费"},
|
||||
// inventoryQuantities 把上传契约的库存字段映射回 publish_options.quantity_items 的 key。
|
||||
// 历史字段名(awmBullets / level6Helmets / level6Armor)保持不变,以兼容既有上传客户端。
|
||||
func inventoryQuantities(inventory ExternalUploadInventory) map[string]int {
|
||||
return map[string]int{
|
||||
"awmAmmo": inventory.AWMBullets,
|
||||
"helmet6": inventory.Level6Helmets,
|
||||
"armor6": inventory.Level6Armor,
|
||||
"level6Ammo": inventory.Level6Ammo,
|
||||
"barrettAmmo": inventory.BarrettAmmo,
|
||||
"gridCard9": inventory.GridCard9,
|
||||
"redPacket45": inventory.RedPacket45,
|
||||
"coffeeBean": inventory.CoffeeBean,
|
||||
"bulletPart": inventory.BulletPart,
|
||||
}
|
||||
}
|
||||
|
||||
// externalResources 按后台实时配置的消耗品清单生成资源数组:条目顺序、名称与单价取自配置,
|
||||
// 数量取自上传数据;未填(<=0)的条目与网站自身发布流程保持一致地省略。
|
||||
func externalResources(inventory ExternalUploadInventory, items []systemconfig.PublishQuantityItem) []any {
|
||||
quantities := inventoryQuantities(inventory)
|
||||
resources := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
quantity := quantities[item.Key]
|
||||
if quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
resources = append(resources, map[string]any{
|
||||
"key": item.Key,
|
||||
"label": item.Label,
|
||||
"price": item.Price,
|
||||
"quantity": quantity,
|
||||
"mode": "收费",
|
||||
})
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
// 与 systemconfig 默认 publish_options 皮肤分组保持一致,避免详情页出现英文分组名。
|
||||
var externalSkinCatalog = []struct {
|
||||
key string
|
||||
names []string
|
||||
}{
|
||||
{"melee", []string{"坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者"}},
|
||||
{"operatorGold", []string{
|
||||
"露娜-古墓丽影联动", "蛊-不羁人生", "红狼-电锯惊魂", "露娜-金牌射手",
|
||||
"牧羊人-街头之星", "蜂医-危险物质", "蜂医-送葬人", "无名-夜鹰",
|
||||
"威龙-壮志凌云", "威龙-蛟龙特战队", "威龙-铁面判官", "威龙-吴彦祖",
|
||||
}},
|
||||
{"operatorRed", []string{"凌霄成卫", "蚀金玫瑰", "水墨云图", "午夜邮差", "天际线", "维什戴尔"}},
|
||||
{"weapon", []string{
|
||||
"M7棱镜攻势S2", "腾龙气象感应", "MP7电玩高手S2", "M250电玩高手S2",
|
||||
"AS Val悬赏令", "K416命运", "M4A1棱镜攻势", "SCAR-H电玩高手",
|
||||
"QBZ95王牌之剑", "AUG气象感应", "Vector美杜莎", "KC17-造物纪元",
|
||||
}},
|
||||
}
|
||||
|
||||
func externalSkinGroups(skins []string) map[string][]string {
|
||||
groups := map[string][]string{}
|
||||
// externalSkinGroups 依据后台实时配置的皮肤分组归类上传的皮肤名,
|
||||
// 避免代码里的字典快照与运营在后台维护的选项脱节(新增皮肤会被误判为“其他皮肤”)。
|
||||
func externalSkinGroups(skins []string, groups []systemconfig.PublishOptionGroup) map[string][]string {
|
||||
result := map[string][]string{}
|
||||
for _, skin := range skins {
|
||||
skin = strings.TrimSpace(skin)
|
||||
if skin == "" {
|
||||
continue
|
||||
}
|
||||
key := classifyExternalSkin(skin)
|
||||
groups[key] = append(groups[key], skin)
|
||||
key := classifyExternalSkin(skin, groups)
|
||||
result[key] = append(result[key], skin)
|
||||
}
|
||||
return groups
|
||||
return result
|
||||
}
|
||||
|
||||
func classifyExternalSkin(skin string) string {
|
||||
func classifyExternalSkin(skin string, groups []systemconfig.PublishOptionGroup) string {
|
||||
// 先精确匹配,再做包含匹配,兼容“红狼-电锯惊魂 / 电锯惊魂”这类写法。
|
||||
for _, group := range externalSkinCatalog {
|
||||
for _, name := range group.names {
|
||||
for _, group := range groups {
|
||||
for _, name := range group.Options {
|
||||
if skin == name {
|
||||
return group.key
|
||||
return group.Key
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, group := range externalSkinCatalog {
|
||||
for _, name := range group.names {
|
||||
for _, group := range groups {
|
||||
for _, name := range group.Options {
|
||||
if strings.Contains(skin, name) || strings.Contains(name, skin) {
|
||||
return group.key
|
||||
return group.Key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
)
|
||||
|
||||
func (s *Service) reviewRequired(ctx context.Context) (bool, error) {
|
||||
@@ -23,6 +25,33 @@ func (s *Service) reviewRequired(ctx context.Context) (bool, error) {
|
||||
return required, nil
|
||||
}
|
||||
|
||||
// externalPublishOptions 读取发布页的实时配置,供外部上传的皮肤归类与消耗品折算使用。
|
||||
//
|
||||
// 外部上传只收平铺的皮肤名与数量,需要按后台配置(而不是代码里的快照)还原分组与单价,
|
||||
// 否则运营在后台新增皮肤/改价后,上传的商品会落进“其他皮肤”且按旧价折算。
|
||||
func (s *Service) externalPublishOptions(ctx context.Context) systemconfig.PublishOptionsDTO {
|
||||
options := systemconfig.DefaultPublishOptions()
|
||||
if s == nil || s.config == nil {
|
||||
return options
|
||||
}
|
||||
value, err := s.config.FindValue(ctx, publishOptionsConfigKey)
|
||||
if err != nil || strings.TrimSpace(value) == "" {
|
||||
return options
|
||||
}
|
||||
var parsed systemconfig.PublishOptionsDTO
|
||||
if err := json.Unmarshal([]byte(value), &parsed); err != nil {
|
||||
return options
|
||||
}
|
||||
// 配置里只覆盖了一部分时,其余字段沿用默认值,避免整份配置失效。
|
||||
if len(parsed.SkinGroups) > 0 {
|
||||
options.SkinGroups = parsed.SkinGroups
|
||||
}
|
||||
if len(parsed.QuantityItems) > 0 {
|
||||
options.QuantityItems = parsed.QuantityItems
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (s *Service) publishRules(ctx context.Context) (publishRules, error) {
|
||||
rules := publishRules{FireLevelMin: defaultFireLevelMin}
|
||||
if s.config == nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
)
|
||||
|
||||
type stubConfigReader map[string]string
|
||||
@@ -503,7 +504,7 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||
Level6Armor: 17,
|
||||
Skins: []string{"信条", "电锯惊魂"},
|
||||
},
|
||||
}, defaultSalePriceConfig())
|
||||
}, defaultSalePriceConfig(), systemconfig.DefaultPublishOptions())
|
||||
|
||||
if req.Description != "有语音封禁\n封禁历史:2026/01/23封禁3天" {
|
||||
t.Fatalf("Description = %q", req.Description)
|
||||
@@ -613,7 +614,7 @@ func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing
|
||||
AWMBullets: 10,
|
||||
Level6Armor: 2,
|
||||
},
|
||||
}, defaultSalePriceConfig())
|
||||
}, defaultSalePriceConfig(), systemconfig.DefaultPublishOptions())
|
||||
|
||||
breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -637,7 +638,7 @@ func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing
|
||||
}
|
||||
|
||||
func TestExternalSkinGroupsClassifiesKnownSkins(t *testing.T) {
|
||||
groups := externalSkinGroups([]string{"暗星", "凌霄成卫", "蛊-不羁人生", "未知皮肤X"})
|
||||
groups := externalSkinGroups([]string{"暗星", "凌霄成卫", "蛊-不羁人生", "未知皮肤X"}, systemconfig.DefaultPublishOptions().SkinGroups)
|
||||
if got := groups["melee"]; len(got) != 1 || got[0] != "暗星" {
|
||||
t.Fatalf("melee = %#v", got)
|
||||
}
|
||||
|
||||
@@ -449,6 +449,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
requirePerm := func(code string) gin.HandlerFunc {
|
||||
return middleware.RequirePermission(code, deps.Redis)
|
||||
}
|
||||
requireAnyPerm := func(codes ...string) gin.HandlerFunc {
|
||||
return middleware.RequireAnyPermission(deps.Redis, codes...)
|
||||
}
|
||||
|
||||
api := engine.Group("/api")
|
||||
{
|
||||
@@ -708,7 +711,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.POST("/distribution/auth/logout", distributionHandler.Logout)
|
||||
adminRoutes.POST("/files/upload", fileHandler.Upload)
|
||||
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
|
||||
adminRoutes.GET("/distribution/sites", requirePerm("distribution:site_view"), distributionHandler.ListSites)
|
||||
adminRoutes.GET("/distribution/sites", requireAnyPerm("distribution:site_view", "qrcode:view"), distributionHandler.ListSites)
|
||||
adminRoutes.GET("/distribution/site-defaults", requirePerm("distribution:site_view"), distributionHandler.GetSiteDefaults)
|
||||
adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite)
|
||||
adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite)
|
||||
|
||||
@@ -113,6 +113,7 @@ const resourceLabels: Record<string, string> = {
|
||||
system_config: '系统配置',
|
||||
audit_log: '审计日志',
|
||||
ad_tracking: '推广追踪',
|
||||
distribution: '分销管理',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -77,10 +77,14 @@ const maxBatchUploadCount = 20
|
||||
const selectedSite = computed(() => sites.value.find(site => site.id === siteId.value))
|
||||
|
||||
async function loadSites() {
|
||||
sites.value = await fetchDistributionSites()
|
||||
const firstSite = sites.value[0]
|
||||
if (!siteId.value && firstSite) {
|
||||
siteId.value = firstSite.id
|
||||
try {
|
||||
sites.value = await fetchDistributionSites()
|
||||
const firstSite = sites.value[0]
|
||||
if (!siteId.value && firstSite) {
|
||||
siteId.value = firstSite.id
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '加载分销站点失败'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,26 +285,16 @@ async function handleLogin() {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 注意:styles/admin.css 里的全局增强规则带 !important,把输入框底色固定成白色
|
||||
(.el-input__wrapper { background-color: #ffffff !important }),本页原设计的深色输入框
|
||||
无法生效;若仍把文字设成浅色就是"白底浅字",输入内容与光标都看不见
|
||||
(验证码框不会被浏览器自动填充,因此最先暴露该问题)。
|
||||
这里统一按浅色输入框配色:深色文字 + 中灰占位符;白底、边框与蓝色焦点环由该全局规则提供。 */
|
||||
.admin-form :deep(.el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.admin-form :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.admin-form :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(20, 119, 255, 0.45) inset,
|
||||
0 0 0 3px rgba(20, 119, 255, 0.08);
|
||||
}
|
||||
.admin-form :deep(.el-input__inner) {
|
||||
color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user