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

271 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}