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 }