271 lines
8.1 KiB
Go
271 lines
8.1 KiB
Go
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 的兩/三碼)。
|
||
|
||
判定時只做代碼相等比對,不做字串模糊比對,也不從貼文猜測縣市 —— 猜錯會把外地需求
|
||
判成在地需求,而使用者要到回覆送出後才發現。判不出來就是 unknown(OP-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
|
||
}
|