thread-master/apps/backend/internal/module/radar/domain/service_profile.go

271 lines
8.1 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
package domain
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
func NowNano() int64 { return time.Now().UTC().UnixNano() }
func NewID() string { return uuid.NewString() }
const (
MaxServiceItems = 20
MaxServiceCases = 20
MaxFaqItems = 30
MaxForbidden = 50
MaxServiceAreas = 22
MaxTextLen = 2000
MaxShortTextLen = 200
)
/*
台灣縣市代碼白名單ISO 3166-2:TW 的兩三碼
判定時只做代碼相等比對不做字串模糊比對也不從貼文猜測縣市 猜錯會把外地需求
判成在地需求而使用者要到回覆送出後才發現判不出來就是 unknownOP-04
unknown 不等於 mismatch
*/
var serviceAreaCodes = map[string]string{
"TPE": "臺北市",
"NWT": "新北市",
"TAO": "桃園市",
"TXG": "臺中市",
"TNN": "臺南市",
"KHH": "高雄市",
"KEE": "基隆市",
"HSZ": "新竹市",
"HSQ": "新竹縣",
"MIA": "苗栗縣",
"CHA": "彰化縣",
"NAN": "南投縣",
"YUN": "雲林縣",
"CYI": "嘉義市",
"CYQ": "嘉義縣",
"PIF": "屏東縣",
"ILA": "宜蘭縣",
"HUA": "花蓮縣",
"TTT": "臺東縣",
"PEN": "澎湖縣",
"KIN": "金門縣",
"LIE": "連江縣",
}
func IsServiceAreaCode(code string) bool {
_, ok := serviceAreaCodes[strings.ToUpper(strings.TrimSpace(code))]
return ok
}
func ServiceAreaLabel(code string) string {
return serviceAreaCodes[strings.ToUpper(strings.TrimSpace(code))]
}
type ServiceItem struct {
Name string `bson:"name" json:"name"`
PriceMin float64 `bson:"price_min,omitempty" json:"price_min,omitempty"`
PriceMax float64 `bson:"price_max,omitempty" json:"price_max,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
}
type ServiceCase struct {
Title string `bson:"title" json:"title"`
Summary string `bson:"summary,omitempty" json:"summary,omitempty"`
Link string `bson:"link,omitempty" json:"link,omitempty"`
}
type FaqItem struct {
Question string `bson:"question" json:"question"`
Answer string `bson:"answer" json:"answer"`
}
/*
ServiceProfile 每會員一份_id 就是 owner_uid
這份檔案是判定與回覆生成的共同輸入沒有它五問判定沒有比對基準回覆也沒有
價格與案例可講所以未建檔時不允許建立 active 訂閱SP-01
*/
type ServiceProfile struct {
OwnerUID int64 `bson:"_id" json:"owner_uid"`
Services []ServiceItem `bson:"services" json:"services"`
Cases []ServiceCase `bson:"cases" json:"cases"`
Forbidden []string `bson:"forbidden" json:"forbidden"`
Faq []FaqItem `bson:"faq" json:"faq"`
ServiceAreas []string `bson:"service_areas" json:"service_areas"`
RemoteOk bool `bson:"remote_ok" json:"remote_ok"`
Availability string `bson:"availability,omitempty" json:"availability,omitempty"`
ToneNote string `bson:"tone_note,omitempty" json:"tone_note,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
/*
Normalize 清掉空白與重複並驗證整份覆寫語意呼叫端送什麼就是全貌
錯誤一律包 ErrValidation 並指名欄位因為使用者看到的是表單訊息要能對到欄位
*/
func (p *ServiceProfile) Normalize() error {
if p.OwnerUID <= 0 {
return fmt.Errorf("%w: owner_uid required", ErrValidation)
}
services := make([]ServiceItem, 0, len(p.Services))
seenService := map[string]bool{}
for i, s := range p.Services {
s.Name = strings.TrimSpace(s.Name)
if s.Name == "" {
return fmt.Errorf("%w: services[%d].name required", ErrValidation, i)
}
if len(s.Name) > MaxShortTextLen {
return fmt.Errorf("%w: services[%d].name too long", ErrValidation, i)
}
if s.PriceMin < 0 || s.PriceMax < 0 {
return fmt.Errorf("%w: services[%d] price must not be negative", ErrValidation, i)
}
// 兩端都填才比大小;只填一端是「起價」或「上限」,都合法。
if s.PriceMin > 0 && s.PriceMax > 0 && s.PriceMin > s.PriceMax {
return fmt.Errorf("%w: services[%d] price_min must not exceed price_max", ErrValidation, i)
}
s.Currency = strings.ToUpper(strings.TrimSpace(s.Currency))
if s.Currency == "" && (s.PriceMin > 0 || s.PriceMax > 0) {
s.Currency = "TWD"
}
if s.Currency != "" && len(s.Currency) != 3 {
return fmt.Errorf("%w: services[%d].currency must be a 3-letter code", ErrValidation, i)
}
key := strings.ToLower(s.Name)
if seenService[key] {
continue
}
seenService[key] = true
services = append(services, s)
}
if len(services) == 0 {
return fmt.Errorf("%w: services required", ErrValidation)
}
if len(services) > MaxServiceItems {
return fmt.Errorf("%w: services exceeds %d items", ErrValidation, MaxServiceItems)
}
p.Services = services
cases := make([]ServiceCase, 0, len(p.Cases))
for i, c := range p.Cases {
c.Title = strings.TrimSpace(c.Title)
c.Summary = strings.TrimSpace(c.Summary)
c.Link = strings.TrimSpace(c.Link)
if c.Title == "" {
return fmt.Errorf("%w: cases[%d].title required", ErrValidation, i)
}
if len(c.Summary) > MaxTextLen {
return fmt.Errorf("%w: cases[%d].summary too long", ErrValidation, i)
}
if c.Link != "" && !strings.HasPrefix(c.Link, "http://") && !strings.HasPrefix(c.Link, "https://") {
return fmt.Errorf("%w: cases[%d].link must be http(s)", ErrValidation, i)
}
cases = append(cases, c)
}
if len(cases) > MaxServiceCases {
return fmt.Errorf("%w: cases exceeds %d items", ErrValidation, MaxServiceCases)
}
p.Cases = cases
p.Forbidden = dedupeStrings(p.Forbidden)
if len(p.Forbidden) > MaxForbidden {
return fmt.Errorf("%w: forbidden exceeds %d items", ErrValidation, MaxForbidden)
}
for i, f := range p.Forbidden {
if len(f) > MaxShortTextLen {
return fmt.Errorf("%w: forbidden[%d] too long", ErrValidation, i)
}
}
faq := make([]FaqItem, 0, len(p.Faq))
for i, f := range p.Faq {
f.Question = strings.TrimSpace(f.Question)
f.Answer = strings.TrimSpace(f.Answer)
if f.Question == "" || f.Answer == "" {
return fmt.Errorf("%w: faq[%d] needs both question and answer", ErrValidation, i)
}
if len(f.Question) > MaxTextLen || len(f.Answer) > MaxTextLen {
return fmt.Errorf("%w: faq[%d] too long", ErrValidation, i)
}
faq = append(faq, f)
}
if len(faq) > MaxFaqItems {
return fmt.Errorf("%w: faq exceeds %d items", ErrValidation, MaxFaqItems)
}
p.Faq = faq
areas := make([]string, 0, len(p.ServiceAreas))
seenArea := map[string]bool{}
for _, a := range p.ServiceAreas {
code := strings.ToUpper(strings.TrimSpace(a))
if code == "" {
continue
}
if !IsServiceAreaCode(code) {
return fmt.Errorf("%w: service_areas contains unknown code %q", ErrValidation, code)
}
if seenArea[code] {
continue
}
seenArea[code] = true
areas = append(areas, code)
}
if len(areas) > MaxServiceAreas {
return fmt.Errorf("%w: service_areas exceeds %d items", ErrValidation, MaxServiceAreas)
}
// 既不接遠端也沒填地區,判定的地區這一問沒有任何依據可用。
if len(areas) == 0 && !p.RemoteOk {
return fmt.Errorf("%w: service_areas required unless remote_ok", ErrValidation)
}
p.ServiceAreas = areas
p.Availability = strings.TrimSpace(p.Availability)
p.ToneNote = strings.TrimSpace(p.ToneNote)
if len(p.Availability) > MaxTextLen {
return fmt.Errorf("%w: availability too long", ErrValidation)
}
if len(p.ToneNote) > MaxTextLen {
return fmt.Errorf("%w: tone_note too long", ErrValidation)
}
return nil
}
// MatchesRegion 回報這份檔案是否服務某縣市代碼。空代碼代表判不出來,交給呼叫端當 unknown 處理。
func (p *ServiceProfile) MatchesRegion(code string) bool {
if p.RemoteOk {
return true
}
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return false
}
for _, a := range p.ServiceAreas {
if a == code {
return true
}
}
return false
}
func dedupeStrings(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
key := strings.ToLower(s)
if seen[key] {
continue
}
seen[key] = true
out = append(out, s)
}
return out
}