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
|
|||
|
|
}
|