feat: migrate workbench services

This commit is contained in:
richjohnrambo
2026-10-10 23:34:06 +08:00
parent c13fb72849
commit 81e3cf1d03
88 changed files with 7358 additions and 15333 deletions
+10
View File
@@ -0,0 +1,10 @@
package mall
import "errors"
var (
ErrCategoryIDInvalid = errors.New("category id invalid")
ErrProductIDInvalid = errors.New("product id invalid")
ErrPageInvalid = errors.New("page invalid")
ErrPageSizeInvalid = errors.New("page size invalid")
)
+149
View File
@@ -0,0 +1,149 @@
package mall
import (
"context"
"html"
"net/url"
"regexp"
"strings"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/store"
)
const (
defaultPageSize = 20
maxPageSize = 100
maxSummaryRunes = 120
)
var (
scriptBlockPattern = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>`)
styleBlockPattern = regexp.MustCompile(`(?is)<style\b[^>]*>.*?</style\s*>`)
htmlTagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
)
type Repository interface {
ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error)
ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error)
}
type Service struct {
repository Repository
}
type Product struct {
ID int64 `json:"id"`
ProductNo string `json:"product_no"`
Category Category `json:"category"`
Name string `json:"name"`
ShopName string `json:"shop_name"`
PriceAmount int64 `json:"price_amount"`
Currency string `json:"currency"`
CoverURL string `json:"cover_url"`
Summary string `json:"summary"`
ClaimedCount int64 `json:"claimed_count"`
IsRecommended bool `json:"is_recommended"`
AvailableActions []string `json:"available_actions"`
PaymentType string `json:"payment_type,omitempty"`
DetailText string `json:"detail_text,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type Category struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
type ProductPage struct {
Items []Product `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
func New(repository Repository) *Service {
return &Service{repository: repository}
}
func (s *Service) Products(ctx context.Context, tenantID, categoryID int64, page, pageSize int) (*ProductPage, error) {
if categoryID < 0 {
return nil, ErrCategoryIDInvalid
}
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = defaultPageSize
}
if page < 1 {
return nil, ErrPageInvalid
}
if pageSize < 1 || pageSize > maxPageSize {
return nil, ErrPageSizeInvalid
}
rows, err := s.repository.ManualProducts(ctx, tenantID, categoryID, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
items := make([]Product, 0, len(rows.Items))
for _, row := range rows.Items {
items = append(items, productFromStore(row, false))
}
return &ProductPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
}
func (s *Service) Product(ctx context.Context, tenantID, productID int64) (*Product, error) {
if productID <= 0 {
return nil, ErrProductIDInvalid
}
row, err := s.repository.ManualProduct(ctx, tenantID, productID)
if err != nil {
return nil, err
}
result := productFromStore(*row, true)
return &result, nil
}
func productFromStore(row store.ManualProduct, detail bool) Product {
detailText := plainText(row.DetailHTML)
result := Product{ID: row.ID, ProductNo: row.ProductNo,
Category: Category{ID: row.CategoryID, Code: row.CategoryCode, Name: row.CategoryName},
Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency,
CoverURL: safeHTTPSURL(row.CoverURL), Summary: truncate(detailText, maxSummaryRunes),
ClaimedCount: row.ClaimedCount, IsRecommended: row.IsRecommended,
AvailableActions: []string{"contact_support"}}
if detail {
result.PaymentType = row.PaymentType
result.DetailText = detailText
result.CreatedAt = row.CreatedAt
result.UpdatedAt = row.UpdatedAt
}
return result
}
func plainText(raw string) string {
raw = scriptBlockPattern.ReplaceAllString(raw, " ")
raw = styleBlockPattern.ReplaceAllString(raw, " ")
withoutTags := htmlTagPattern.ReplaceAllString(raw, " ")
return strings.Join(strings.Fields(html.UnescapeString(withoutTags)), " ")
}
func truncate(value string, limit int) string {
if utf8.RuneCountInString(value) <= limit {
return value
}
runes := []rune(value)
return string(runes[:limit]) + "…"
}
func safeHTTPSURL(raw string) string {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return ""
}
return parsed.String()
}
+61
View File
@@ -0,0 +1,61 @@
package mall
import (
"context"
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct {
items []store.ManualProduct
detail *store.ManualProduct
}
func (f *fakeRepository) ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error) {
return &store.ManualProductPage{Items: f.items, Total: int64(len(f.items))}, nil
}
func (f *fakeRepository) ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error) {
return f.detail, nil
}
func TestProductsMapsSafeCard(t *testing.T) {
service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03",
CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台",
PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png",
DetailHTML: "<h3>标题</h3><script>alert(1)</script><p>内容</p>", ClaimedCount: 2, IsRecommended: true}}})
result, err := service.Products(context.Background(), 0, 0, 1, 20)
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 1 || result.Items[0].CoverURL == "" || result.Items[0].Summary != "标题 内容" ||
len(result.Items[0].AvailableActions) != 1 || result.Items[0].AvailableActions[0] != "contact_support" {
t.Fatalf("unexpected product: %#v", result.Items)
}
}
func TestProductRejectsUnsafeCoverAndReturnsPlainText(t *testing.T) {
service := New(&fakeRepository{detail: &store.ManualProduct{ID: 4, Name: "商品",
CoverURL: "javascript:alert(1)", DetailHTML: "<p>安全说明</p>"}})
result, err := service.Product(context.Background(), 0, 4)
if err != nil {
t.Fatal(err)
}
if result.CoverURL != "" || result.DetailText != "安全说明" {
t.Fatalf("unexpected detail: %#v", result)
}
}
func TestProductsValidatesPagination(t *testing.T) {
service := New(&fakeRepository{})
if _, err := service.Products(context.Background(), 0, 0, -1, 20); !errors.Is(err, ErrPageInvalid) {
t.Fatalf("expected page error, got %v", err)
}
if _, err := service.Products(context.Background(), 0, -1, 1, 20); !errors.Is(err, ErrCategoryIDInvalid) {
t.Fatalf("expected category error, got %v", err)
}
}