diff --git a/apps/backend/cmd/init/main.go b/apps/backend/cmd/init/main.go index d68a72f..ca41364 100644 --- a/apps/backend/cmd/init/main.go +++ b/apps/backend/cmd/init/main.go @@ -164,6 +164,9 @@ func indexModels() map[string][]mongo.IndexModel { // demand-radar. Names and specs match migration 000014 exactly so both paths are // idempotent; the two unique keys (radar_opportunities.owner_opportunity_external and // crm_contacts.owner_contact_identity) are intentionally absent and owned by that migration. + "radar_schedules": { + {Keys: bson.D{{Key: "updated_at", Value: 1}}, Options: options.Index().SetName("schedule_updated")}, + }, "radar_watches": { {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "status", Value: 1}}, Options: options.Index().SetName("owner_watches_status")}, ownerIndex("created_at", "owner_watches_created"), diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go index c082764..4700534 100644 --- a/apps/backend/cmd/worker/main.go +++ b/apps/backend/cmd/worker/main.go @@ -323,7 +323,7 @@ func runMaintenance( } else if purged > 0 { logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged) } - // 雷達每日排程:UTC 22:00 之後為每個 active watch 建一筆 radar_sweep(同日去重)。 + // 雷達排程:為每個 active watch 補齊今天(台北)已到期的時段 Job。 if n, err := radarSvc.ScheduleDailySweeps(ctx, time.Now().UTC()); err != nil { logx.Errorf("worker %s radar daily schedule: %v", workerID, err) } else if n > 0 { diff --git a/apps/backend/crawler/src/server.ts b/apps/backend/crawler/src/server.ts index 5f29d3a..6bca7ef 100644 --- a/apps/backend/crawler/src/server.ts +++ b/apps/backend/crawler/src/server.ts @@ -115,24 +115,34 @@ async function readPosts(page: Page, query: string, limit: number): Promise s.replace(/\s+/g, " ").trim(); + const keepBreaks = (s: string) => + s + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n") + .map((line) => line.replace(/[ \t\u00a0\u3000]+/g, " ").trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + // 找小範圍卡片:往上最多 8 層。長度用壓平後的字數判斷,正文保留換行。 let el: HTMLElement | null = a; let best = ""; for (let depth = 0; depth < 8 && el; depth++) { - const t = (el.innerText || "").replace(/\s+/g, " ").trim(); + const raw = el.innerText || ""; + const t = flatten(raw); if (t.length >= 20 && t.length <= 1200) { - best = t; - // 再往上若突然暴衝(整欄 feed)就停在 best + best = keepBreaks(raw); const parent = el.parentElement; if (parent) { - const pt = (parent.innerText || "").replace(/\s+/g, " ").trim(); + const pt = flatten(parent.innerText || ""); if (pt.length > t.length * 3 && pt.length > 1500) break; } } el = el.parentElement; } - if (best.length < 12) { - best = (a.innerText || "").replace(/\s+/g, " ").trim(); + if (flatten(best).length < 12) { + best = keepBreaks(a.innerText || ""); } if (best.length < 8) continue; const author = href.match(/@([^/]+)\/post/)?.[1] || ""; diff --git a/apps/backend/generate/api/radar.api b/apps/backend/generate/api/radar.api index 58e9baf..ddf81c3 100644 --- a/apps/backend/generate/api/radar.api +++ b/apps/backend/generate/api/radar.api @@ -95,6 +95,17 @@ type ( ProductId string `json:"product_id,optional"` } + // Hours are 0–23 in Asia/Taipei. Empty GET means default [6] (台北 06:00). + // int64:go-zero JSON 對 []int 常解不出來,會變成 400001。 + RadarSchedulePublic { + Hours []int64 `json:"hours"` + Timezone string `json:"timezone"` + } + + PutRadarScheduleReq { + Hours []int64 `json:"hours,optional"` + } + UpdateWatchReq { Id string `path:"id"` Terms []string `json:"terms,optional"` @@ -545,6 +556,12 @@ service gateway { @handler UpsertServiceProfile put /service-profile (UpsertServiceProfileReq) returns (ServiceProfilePublic) + @handler GetRadarSchedule + get /schedule returns (RadarSchedulePublic) + + @handler PutRadarSchedule + put /schedule (PutRadarScheduleReq) returns (RadarSchedulePublic) + @handler ListWatches get /watches (ListWatchesReq) returns (WatchListData) diff --git a/apps/backend/internal/handler/radar/get_radar_schedule_handler.go b/apps/backend/internal/handler/radar/get_radar_schedule_handler.go new file mode 100644 index 0000000..92eb7df --- /dev/null +++ b/apps/backend/internal/handler/radar/get_radar_schedule_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetRadarScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := radar.NewGetRadarScheduleLogic(r.Context(), svcCtx) + data, err := l.GetRadarSchedule() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/put_radar_schedule_handler.go b/apps/backend/internal/handler/radar/put_radar_schedule_handler.go new file mode 100644 index 0000000..8832e32 --- /dev/null +++ b/apps/backend/internal/handler/radar/put_radar_schedule_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PutRadarScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PutRadarScheduleReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewPutRadarScheduleLogic(r.Context(), svcCtx) + data, err := l.PutRadarSchedule(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go index 81a335a..edee758 100644 --- a/apps/backend/internal/handler/routes.go +++ b/apps/backend/internal/handler/routes.go @@ -623,11 +623,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { []rest.Route{ { Method: http.MethodPost, - Path: "/upload", - Handler: media.UploadHandler(serverCtx), + Path: "/generate-image", + Handler: media.GenerateImageHandler(serverCtx), }, }..., ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/v1/media"), ) @@ -637,12 +638,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { []rest.Route{ { Method: http.MethodPost, - Path: "/generate-image", - Handler: media.GenerateImageHandler(serverCtx), + Path: "/upload", + Handler: media.UploadHandler(serverCtx), }, }..., ), - rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/v1/media"), ) @@ -1126,6 +1126,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/products/:productId/demand-map/enrich", Handler: radar.EnrichDemandMapHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/schedule", + Handler: radar.GetRadarScheduleHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/schedule", + Handler: radar.PutRadarScheduleHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/service-profile", diff --git a/apps/backend/internal/logic/radar/get_radar_schedule_logic.go b/apps/backend/internal/logic/radar/get_radar_schedule_logic.go new file mode 100644 index 0000000..6985820 --- /dev/null +++ b/apps/backend/internal/logic/radar/get_radar_schedule_logic.go @@ -0,0 +1,40 @@ +package radar + +import ( + "context" + + radarDomain "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetRadarScheduleLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetRadarScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRadarScheduleLogic { + return &GetRadarScheduleLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRadarScheduleLogic) GetRadarSchedule() (resp *types.RadarSchedulePublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + row, err := l.svcCtx.Radar.GetRadarSchedule(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.RadarSchedulePublic{ + Hours: int64Hours(row.Hours), + Timezone: radarDomain.ScheduleTimezone, + }, nil +} diff --git a/apps/backend/internal/logic/radar/list_watches_logic.go b/apps/backend/internal/logic/radar/list_watches_logic.go index bf5eb21..de942b6 100644 --- a/apps/backend/internal/logic/radar/list_watches_logic.go +++ b/apps/backend/internal/logic/radar/list_watches_logic.go @@ -43,7 +43,7 @@ func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.W if err != nil { return nil, err } - // profile_exists 讓雷達頁能在建訂閱之前就先引導建檔,而不是等 POST 被拒。 + // profile_exists 讓雷達頁顯示「服務檔案可之後再補」,不再當硬門檻。 hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid) if err != nil { return nil, err diff --git a/apps/backend/internal/logic/radar/m1_integration_test.go b/apps/backend/internal/logic/radar/m1_integration_test.go index 9ca48c9..7aeaeca 100644 --- a/apps/backend/internal/logic/radar/m1_integration_test.go +++ b/apps/backend/internal/logic/radar/m1_integration_test.go @@ -129,30 +129,21 @@ func (e *m1Env) sweepCandidates(t *testing.T) []string { return ids } -// SP-01:新會員沒有服務檔案就建 active watch → 明確錯誤,且訊息要指向服務檔案。 -func TestM1_SP01_ActiveWatchWithoutServiceProfileIsRejected(t *testing.T) { +func TestM1_SP01_ActiveWatchWithoutServiceProfileIsAllowed(t *testing.T) { env := newM1Env(t, 42, 5) - _, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ + w, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{"婚攝 推薦"}, Enabled: true, }) - if err == nil { - t.Fatal("SP-01: active watch was created without a service profile") + if err != nil { + t.Fatalf("SP-01: active watch without profile: %v", err) } - envelope := assertStatus(t, err, http.StatusBadRequest, 400100) - if !strings.Contains(envelope.Message, "service-profile") { - t.Fatalf("SP-01: message must point at the service profile, got %q", envelope.Message) + if w.Status != "active" { + t.Fatalf("SP-01: status = %q, want active", w.Status) } - // 擋下之後不能留半筆:使用者回頭填完檔案,配額要從 0 開始算。 - if got := env.list(t, ""); got.Pagination.Total != 0 { - t.Fatalf("SP-01: rejected create left %d watches behind", got.Pagination.Total) - } - - // 停用狀態的 watch 不占用巡的資源,所以允許先建起來備用。 - env.createWatch(t, "婚攝 推薦", false) - if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 0 { - t.Fatalf("SP-01: paused watch should be allowed without a profile, got %+v", got) + if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 1 { + t.Fatalf("SP-01: list = %+v, want one active watch", got) } } diff --git a/apps/backend/internal/logic/radar/owner.go b/apps/backend/internal/logic/radar/owner.go index 4fab28f..4a8f2d0 100644 --- a/apps/backend/internal/logic/radar/owner.go +++ b/apps/backend/internal/logic/radar/owner.go @@ -20,3 +20,19 @@ func ownerUID(ctx context.Context) (int64, error) { } return uid, nil } + +func intHours(in []int64) []int { + out := make([]int, 0, len(in)) + for _, h := range in { + out = append(out, int(h)) + } + return out +} + +func int64Hours(in []int) []int64 { + out := make([]int64, 0, len(in)) + for _, h := range in { + out = append(out, int64(h)) + } + return out +} diff --git a/apps/backend/internal/logic/radar/put_radar_schedule_logic.go b/apps/backend/internal/logic/radar/put_radar_schedule_logic.go new file mode 100644 index 0000000..18487d7 --- /dev/null +++ b/apps/backend/internal/logic/radar/put_radar_schedule_logic.go @@ -0,0 +1,44 @@ +package radar + +import ( + "context" + + radarDomain "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PutRadarScheduleLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPutRadarScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PutRadarScheduleLogic { + return &PutRadarScheduleLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *PutRadarScheduleLogic) PutRadarSchedule(req *types.PutRadarScheduleReq) (resp *types.RadarSchedulePublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + hours := []int{} + if req != nil { + hours = intHours(req.Hours) + } + row, err := l.svcCtx.Radar.PutRadarSchedule(l.ctx, uid, hours) + if err != nil { + return nil, err + } + return &types.RadarSchedulePublic{ + Hours: int64Hours(row.Hours), + Timezone: radarDomain.ScheduleTimezone, + }, nil +} diff --git a/apps/backend/internal/logic/radar/schedule_logic_test.go b/apps/backend/internal/logic/radar/schedule_logic_test.go new file mode 100644 index 0000000..48d7c42 --- /dev/null +++ b/apps/backend/internal/logic/radar/schedule_logic_test.go @@ -0,0 +1,22 @@ +package radar + +import ( + "encoding/json" + "testing" + + "apps/backend/internal/types" +) + +func TestPutRadarScheduleReqJSON(t *testing.T) { + var req types.PutRadarScheduleReq + if err := json.Unmarshal([]byte(`{"hours":[6,18,21]}`), &req); err != nil { + t.Fatal(err) + } + if len(req.Hours) != 3 || req.Hours[0] != 6 || req.Hours[2] != 21 { + t.Fatalf("hours=%v", req.Hours) + } + got := intHours(req.Hours) + if len(got) != 3 || got[1] != 18 { + t.Fatalf("intHours=%v", got) + } +} diff --git a/apps/backend/internal/logic/radar/watch_logic_test.go b/apps/backend/internal/logic/radar/watch_logic_test.go index 0a7b0d2..39de922 100644 --- a/apps/backend/internal/logic/radar/watch_logic_test.go +++ b/apps/backend/internal/logic/radar/watch_logic_test.go @@ -39,20 +39,18 @@ func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, return w } -// SP-01:沒建服務檔案就建 active 訂閱 → 400100,訊息要指向服務檔案而不是只說「失敗」。 -func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) { +func TestCreateActiveWatchWithoutProfileSucceeds(t *testing.T) { ctx, svcCtx := watchCtx(t, 42, 5, false) - _, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{ + w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{"婚攝 推薦"}, Enabled: true, }) - if err == nil { - t.Fatal("active watch created without a service profile") + if err != nil { + t.Fatalf("active watch without profile: %v", err) } - env := assertStatus(t, err, http.StatusBadRequest, 400100) - if !strings.Contains(env.Message, "service-profile") { - t.Fatalf("message must point at the service profile, got %q", env.Message) + if w.Status != "active" { + t.Fatalf("status = %q, want active", w.Status) } } diff --git a/apps/backend/internal/logic/radarmap/map.go b/apps/backend/internal/logic/radarmap/map.go index d47b310..a593de2 100644 --- a/apps/backend/internal/logic/radarmap/map.go +++ b/apps/backend/internal/logic/radarmap/map.go @@ -40,7 +40,7 @@ func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic { ServiceProfile 把 domain 檔案轉成 API 形狀。 p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。 -但 exists 這個欄位必須誠實,訂閱閘門(SP-01)與前端引導都看它。 +但 exists 這個欄位必須誠實,前端用它決定要不要顯示「之後再補」提示。 */ func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic { if p == nil { diff --git a/apps/backend/internal/module/job/usecase/service.go b/apps/backend/internal/module/job/usecase/service.go index 2447999..d1d812b 100644 --- a/apps/backend/internal/module/job/usecase/service.go +++ b/apps/backend/internal/module/job/usecase/service.go @@ -160,13 +160,28 @@ type RadarSweepPayload struct { Day string `json:"day"` } -// RadarSweepRef builds the job RefID for a watch's daily slot. +// RadarSweepRef builds the job RefID for a watch's Taipei local date+hour slot. func RadarSweepRef(watchID, day string) string { return strings.TrimSpace(watchID) + ":" + strings.TrimSpace(day) } -// ScheduleRadarSweep enqueues one radar_sweep job for a watch on the UTC day of runAt. -// If a job for the same watch+day already exists (any status), returns it without inserting. +func taipeiLoc() *time.Location { + loc, err := time.LoadLocation("Asia/Taipei") + if err != nil { + return time.FixedZone("Asia/Taipei", 8*3600) + } + return loc +} + +func radarSweepSlotRef(watchID string, runAt int64) (ref, day string) { + local := time.Unix(0, runAt).In(taipeiLoc()) + day = local.Format("2006-01-02") + ref = fmt.Sprintf("%s:tp:%s:%02d", strings.TrimSpace(watchID), day, local.Hour()) + return ref, day +} + +// ScheduleRadarSweep enqueues one radar_sweep job for a watch on the Taipei slot of runAt. +// If a job for the same watch+date+hour already exists (any status), returns it without inserting. func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) { watchID = strings.TrimSpace(watchID) if ownerUID <= 0 || watchID == "" { @@ -175,8 +190,7 @@ func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchI if runAt <= 0 { runAt = domain.NowNano() } - day := time.Unix(0, runAt).UTC().Format("2006-01-02") - ref := RadarSweepRef(watchID, day) + ref, day := radarSweepSlotRef(watchID, runAt) body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day}) if err != nil { diff --git a/apps/backend/internal/module/radar/domain/repository.go b/apps/backend/internal/module/radar/domain/repository.go index 7b9c276..1e8b37c 100644 --- a/apps/backend/internal/module/radar/domain/repository.go +++ b/apps/backend/internal/module/radar/domain/repository.go @@ -11,6 +11,9 @@ type Repository interface { GetServiceProfile(ctx context.Context, ownerUID int64) (*ServiceProfile, error) SaveServiceProfile(ctx context.Context, p *ServiceProfile) error + GetRadarSchedule(ctx context.Context, ownerUID int64) (*RadarSchedule, error) + SaveRadarSchedule(ctx context.Context, s *RadarSchedule) error + // DemandMap is the product-specific, user-editable search contract. GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*DemandMap, error) SaveDemandMap(ctx context.Context, m *DemandMap, expectedVersion int64) (*DemandMap, error) diff --git a/apps/backend/internal/module/radar/domain/schedule.go b/apps/backend/internal/module/radar/domain/schedule.go new file mode 100644 index 0000000..f5c5193 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/schedule.go @@ -0,0 +1,92 @@ +package domain + +import ( + "fmt" + "sort" + "time" +) + +const ( + ScheduleTimezone = "Asia/Taipei" + DefaultSweepHour = 6 + MaxSweepHours = 6 + minSweepHour = 0 + maxSweepHour = 23 +) + +// RadarSchedule is the owner's automatic patrol timetable (Taipei local hours). +type RadarSchedule struct { + OwnerUID int64 `bson:"_id" json:"owner_uid"` + Hours []int `bson:"hours" json:"hours"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +func TaipeiLocation() *time.Location { + loc, err := time.LoadLocation(ScheduleTimezone) + if err != nil { + return time.FixedZone(ScheduleTimezone, 8*3600) + } + return loc +} + +func DefaultSweepHours() []int { + return []int{DefaultSweepHour} +} + +func NormalizeSweepHours(hours []int) ([]int, error) { + if len(hours) == 0 { + return DefaultSweepHours(), nil + } + seen := map[int]bool{} + out := make([]int, 0, len(hours)) + for _, h := range hours { + if h < minSweepHour || h > maxSweepHour { + return nil, fmt.Errorf("%w: hours must be 0–23 (got %d)", ErrValidation, h) + } + if seen[h] { + continue + } + seen[h] = true + out = append(out, h) + } + if len(out) == 0 { + return DefaultSweepHours(), nil + } + if len(out) > MaxSweepHours { + return nil, fmt.Errorf("%w: at most %d patrol hours", ErrValidation, MaxSweepHours) + } + sort.Ints(out) + return out, nil +} + +// SweepSlot is one due automatic patrol (Taipei calendar day + hour). +type SweepSlot struct { + Date string + Hour int + RunAt int64 +} + +// DueSweepSlots returns selected hours that have already started today (Taipei) +// plus any earlier selected hours the same day, so a late worker still catches up. +func DueSweepSlots(now time.Time, hours []int) []SweepSlot { + hours, err := NormalizeSweepHours(hours) + if err != nil { + hours = DefaultSweepHours() + } + loc := TaipeiLocation() + local := now.In(loc) + day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc) + out := make([]SweepSlot, 0, len(hours)) + for _, h := range hours { + slot := day.Add(time.Duration(h) * time.Hour) + if local.Before(slot) { + continue + } + out = append(out, SweepSlot{ + Date: day.Format("2006-01-02"), + Hour: h, + RunAt: slot.UnixNano(), + }) + } + return out +} diff --git a/apps/backend/internal/module/radar/domain/schedule_test.go b/apps/backend/internal/module/radar/domain/schedule_test.go new file mode 100644 index 0000000..04601c2 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/schedule_test.go @@ -0,0 +1,48 @@ +package domain + +import ( + "testing" + "time" +) + +func TestNormalizeSweepHours(t *testing.T) { + got, err := NormalizeSweepHours(nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != 6 { + t.Fatalf("empty → default [6], got %v", got) + } + got, err = NormalizeSweepHours([]int{18, 6, 6, 12}) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[0] != 6 || got[1] != 12 || got[2] != 18 { + t.Fatalf("dedupe+sort, got %v", got) + } + if _, err := NormalizeSweepHours([]int{24}); err == nil { + t.Fatal("hour 24 should fail") + } + if _, err := NormalizeSweepHours([]int{0, 3, 6, 9, 12, 15, 18}); err == nil { + t.Fatal("too many hours should fail") + } +} + +func TestDueSweepSlots_TaipeiHours(t *testing.T) { + loc := TaipeiLocation() + // 05:59 Taipei 31 Jul → no 06:00 slot yet + before := time.Date(2026, 7, 31, 5, 59, 0, 0, loc) + if slots := DueSweepSlots(before, []int{6, 18}); len(slots) != 0 { + t.Fatalf("before 06:00 want 0, got %+v", slots) + } + at := time.Date(2026, 7, 31, 6, 0, 0, 0, loc) + slots := DueSweepSlots(at, []int{6, 18}) + if len(slots) != 1 || slots[0].Hour != 6 { + t.Fatalf("at 06:00 want [6], got %+v", slots) + } + evening := time.Date(2026, 7, 31, 18, 5, 0, 0, loc) + slots = DueSweepSlots(evening, []int{6, 18}) + if len(slots) != 2 || slots[0].Hour != 6 || slots[1].Hour != 18 { + t.Fatalf("after 18:00 want [6,18], got %+v", slots) + } +} diff --git a/apps/backend/internal/module/radar/domain/service_profile.go b/apps/backend/internal/module/radar/domain/service_profile.go index acd4bf9..65749c4 100644 --- a/apps/backend/internal/module/radar/domain/service_profile.go +++ b/apps/backend/internal/module/radar/domain/service_profile.go @@ -84,8 +84,7 @@ type FaqItem struct { /* ServiceProfile 每會員一份,_id 就是 owner_uid。 -這份檔案是判定與回覆生成的共同輸入:沒有它,五問判定沒有比對基準,回覆也沒有 -價格與案例可講,所以未建檔時不允許建立 active 訂閱(SP-01)。 +這份檔案是判定與回覆生成的加分輸入:沒有它仍可用關鍵字巡邏,只是比對與回覆較通用。 */ type ServiceProfile struct { OwnerUID int64 `bson:"_id" json:"owner_uid"` diff --git a/apps/backend/internal/module/radar/repository/service_profile_memory.go b/apps/backend/internal/module/radar/repository/service_profile_memory.go index 073f73c..c230e73 100644 --- a/apps/backend/internal/module/radar/repository/service_profile_memory.go +++ b/apps/backend/internal/module/radar/repository/service_profile_memory.go @@ -12,6 +12,7 @@ import ( type Memory struct { mu sync.Mutex profiles map[int64]*domain.ServiceProfile + schedules map[int64]*domain.RadarSchedule demandMaps map[string]*domain.DemandMap watches map[string]*domain.RadarWatch opportunities map[string]*domain.Opportunity @@ -27,6 +28,7 @@ type Memory struct { func NewMemory() *Memory { return &Memory{ profiles: map[int64]*domain.ServiceProfile{}, + schedules: map[int64]*domain.RadarSchedule{}, demandMaps: map[string]*domain.DemandMap{}, watches: map[string]*domain.RadarWatch{}, opportunities: map[string]*domain.Opportunity{}, @@ -60,3 +62,24 @@ func (m *Memory) SaveServiceProfile(_ context.Context, p *domain.ServiceProfile) m.profiles[p.OwnerUID] = &cp return nil } + +func (m *Memory) GetRadarSchedule(_ context.Context, ownerUID int64) (*domain.RadarSchedule, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.schedules[ownerUID] + if !ok { + return nil, domain.ErrNotFound + } + cp := *s + cp.Hours = append([]int(nil), s.Hours...) + return &cp, nil +} + +func (m *Memory) SaveRadarSchedule(_ context.Context, s *domain.RadarSchedule) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *s + cp.Hours = append([]int(nil), s.Hours...) + m.schedules[s.OwnerUID] = &cp + return nil +} diff --git a/apps/backend/internal/module/radar/repository/service_profile_mongo.go b/apps/backend/internal/module/radar/repository/service_profile_mongo.go index 2f1a6e5..bd4b36a 100644 --- a/apps/backend/internal/module/radar/repository/service_profile_mongo.go +++ b/apps/backend/internal/module/radar/repository/service_profile_mongo.go @@ -13,6 +13,7 @@ import ( type MonStore struct { profiles *mon.Model + schedules *mon.Model demandMaps *mon.Model watches *mon.Model opportunities *mon.Model @@ -25,6 +26,7 @@ func NewMonStore(uri, database string) *MonStore { uri = libmongo.MustMongoURI(uri) return &MonStore{ profiles: mon.MustNewModel(uri, database, "radar_service_profiles"), + schedules: mon.MustNewModel(uri, database, "radar_schedules"), demandMaps: mon.MustNewModel(uri, database, "radar_demand_maps"), watches: mon.MustNewModel(uri, database, "radar_watches"), opportunities: mon.MustNewModel(uri, database, "radar_opportunities"), @@ -52,3 +54,20 @@ func (s *MonStore) SaveServiceProfile(ctx context.Context, p *domain.ServiceProf _, err := s.profiles.ReplaceOne(ctx, bson.M{"_id": p.OwnerUID}, p, options.Replace().SetUpsert(true)) return err } + +func (s *MonStore) GetRadarSchedule(ctx context.Context, ownerUID int64) (*domain.RadarSchedule, error) { + var row domain.RadarSchedule + err := s.schedules.FindOne(ctx, &row, bson.M{"_id": ownerUID}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *MonStore) SaveRadarSchedule(ctx context.Context, row *domain.RadarSchedule) error { + _, err := s.schedules.ReplaceOne(ctx, bson.M{"_id": row.OwnerUID}, row, options.Replace().SetUpsert(true)) + return err +} diff --git a/apps/backend/internal/module/radar/usecase/billing.go b/apps/backend/internal/module/radar/usecase/billing.go index 56e81e4..c59abff 100644 --- a/apps/backend/internal/module/radar/usecase/billing.go +++ b/apps/backend/internal/module/radar/usecase/billing.go @@ -2,6 +2,7 @@ package usecase import ( "context" + "time" "github.com/zeromicro/go-zero/core/logx" ) @@ -57,7 +58,10 @@ func (c *charge) Release(ctx context.Context) { return } c.settled = true - if err := c.svc.Usage.ReleaseCall(ctx, c.uid, c.meter, c.mode); err != nil { + // 要補償的失敗常常就是「請求被取消」,退點必須用活得比它久的 context,否則使用者被扣點。 + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := c.svc.Usage.ReleaseCall(rctx, c.uid, c.meter, c.mode); err != nil { logx.Errorf("usage release uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err) } } diff --git a/apps/backend/internal/module/radar/usecase/candidate_prefilter.go b/apps/backend/internal/module/radar/usecase/candidate_prefilter.go index 4e7f192..36af9fb 100644 --- a/apps/backend/internal/module/radar/usecase/candidate_prefilter.go +++ b/apps/backend/internal/module/radar/usecase/candidate_prefilter.go @@ -33,7 +33,7 @@ func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost { if out.Permalink == "" { out.Permalink = out.ExternalID } - out.Text = strings.TrimSpace(strings.Join(strings.Fields(out.Text), " ")) + out.Text = NormalizePostBody(out.Text) out.Title = strings.TrimSpace(strings.Join(strings.Fields(out.Title), " ")) out.AuthorHandle = strings.TrimPrefix(strings.TrimSpace(out.AuthorHandle), "@") out.Classification = strings.ToLower(strings.TrimSpace(out.Classification)) @@ -43,6 +43,49 @@ func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost { return &out } +// NormalizePostBody keeps paragraph breaks so the inbox can show the post as +// written. Horizontal runs of space/tab are still collapsed. +func NormalizePostBody(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = strings.ReplaceAll(s, "\u2028", "\n") + s = strings.ReplaceAll(s, "\u2029", "\n") + lines := strings.Split(s, "\n") + out := make([]string, 0, len(lines)) + blank := 0 + for _, line := range lines { + line = collapseHorizontalSpace(line) + if line == "" { + blank++ + if blank == 1 { + out = append(out, "") + } + continue + } + blank = 0 + out = append(out, line) + } + return strings.Trim(strings.Join(out, "\n"), "\n") +} + +func collapseHorizontalSpace(s string) string { + var b strings.Builder + b.Grow(len(s)) + space := false + for _, r := range s { + if r == ' ' || r == '\t' || r == '\u00a0' || r == '\u3000' { + if !space { + b.WriteByte(' ') + space = true + } + continue + } + space = false + b.WriteRune(r) + } + return strings.TrimSpace(b.String()) +} + func canonicalCandidateURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/apps/backend/internal/module/radar/usecase/candidate_prefilter_test.go b/apps/backend/internal/module/radar/usecase/candidate_prefilter_test.go index 8e0265a..f559f9d 100644 --- a/apps/backend/internal/module/radar/usecase/candidate_prefilter_test.go +++ b/apps/backend/internal/module/radar/usecase/candidate_prefilter_test.go @@ -21,6 +21,21 @@ func TestPrefilterNormalizesDedupesAndKeepsWeakDemandForReview(t *testing.T) { } } +func TestNormalizePostBodyKeepsParagraphs(t *testing.T) { + got := NormalizePostBody(" 最近皮膚泛紅\n\n\n換季怎麼辦 \t有人知道嗎 ") + want := "最近皮膚泛紅\n\n換季怎麼辦 有人知道嗎" + if got != want { + t.Fatalf("got %q want %q", got, want) + } + c := NormalizeCandidate(&domain.CandidatePost{ + Permalink: "https://threads.net/@a/post/1", + Text: "第一段\n第二段", + }) + if c.Text != "第一段\n第二段" { + t.Fatalf("candidate text collapsed: %q", c.Text) + } +} + func TestPrefilterDoesNotUseProductNameAsDemandSignal(t *testing.T) { items, stats := PrefilterCandidates([]*domain.CandidatePost{{Permalink: "https://threads.net/@a/post/1", Text: "舒緩精華好漂亮"}}, nil, &ProductContextSnapshot{ProductLabel: "舒緩精華", PainPoints: []string{"泛紅"}}) if len(items) != 1 || stats.Review != 1 || stats.Rejected != 0 { diff --git a/apps/backend/internal/module/radar/usecase/product_watch_test.go b/apps/backend/internal/module/radar/usecase/product_watch_test.go index 224490f..f7d0e07 100644 --- a/apps/backend/internal/module/radar/usecase/product_watch_test.go +++ b/apps/backend/internal/module/radar/usecase/product_watch_test.go @@ -35,8 +35,8 @@ func TestGenericWatchKeepsProfileGateAndAssignIsOneWay(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := svc.ResumeWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) { - t.Fatalf("generic watch without profile err=%v", err) + if _, err := svc.ResumeWatch(ctx, 42, w.ID); err != nil { + t.Fatalf("generic watch without profile should resume: %v", err) } assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1") if err != nil { diff --git a/apps/backend/internal/module/radar/usecase/service_profile.go b/apps/backend/internal/module/radar/usecase/service_profile.go index c4a6618..39b2679 100644 --- a/apps/backend/internal/module/radar/usecase/service_profile.go +++ b/apps/backend/internal/module/radar/usecase/service_profile.go @@ -67,8 +67,7 @@ func New(repo domain.Repository) *Service { GetServiceProfile 未建檔時回 domain.ErrNotFound,由呼叫端決定怎麼表達。 HTTP 層會把它翻成 exists=false 的 200(表單本來就要能開空的),但 usecase 不能 -自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來,而 SP-01 的 -訂閱閘門正是靠這個差別。 +自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來。 */ func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) { if ownerUID <= 0 { @@ -77,6 +76,40 @@ func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domai return s.Repo.GetServiceProfile(ctx, ownerUID) } +func (s *Service) GetRadarSchedule(ctx context.Context, ownerUID int64) (*domain.RadarSchedule, error) { + if ownerUID <= 0 { + return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) + } + row, err := s.Repo.GetRadarSchedule(ctx, ownerUID) + if errors.Is(err, domain.ErrNotFound) || row == nil { + return &domain.RadarSchedule{OwnerUID: ownerUID, Hours: domain.DefaultSweepHours()}, nil + } + if err != nil { + return nil, err + } + hours, nerr := domain.NormalizeSweepHours(row.Hours) + if nerr != nil { + hours = domain.DefaultSweepHours() + } + row.Hours = hours + return row, nil +} + +func (s *Service) PutRadarSchedule(ctx context.Context, ownerUID int64, hours []int) (*domain.RadarSchedule, error) { + if ownerUID <= 0 { + return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) + } + normalized, err := domain.NormalizeSweepHours(hours) + if err != nil { + return nil, err + } + row := &domain.RadarSchedule{OwnerUID: ownerUID, Hours: normalized, UpdatedAt: domain.NowNano()} + if err := s.Repo.SaveRadarSchedule(ctx, row); err != nil { + return nil, err + } + return row, nil +} + func (s *Service) HasServiceProfile(ctx context.Context, ownerUID int64) (bool, error) { _, err := s.GetServiceProfile(ctx, ownerUID) if errors.Is(err, domain.ErrNotFound) { diff --git a/apps/backend/internal/module/radar/usecase/service_profile_test.go b/apps/backend/internal/module/radar/usecase/service_profile_test.go index fd9c675..b0bd3d3 100644 --- a/apps/backend/internal/module/radar/usecase/service_profile_test.go +++ b/apps/backend/internal/module/radar/usecase/service_profile_test.go @@ -128,7 +128,7 @@ func TestUpsertRequiresAtLeastOneService(t *testing.T) { } } -// 「沒建檔」與「建了一份空的」必須分得出來:SP-01 的訂閱閘門靠這個差別。 +// 「沒建檔」與「建了一份空的」必須分得出來:列表的 profile_exists 靠這個差別。 func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) { svc := newTestService() ctx := context.Background() diff --git a/apps/backend/internal/module/radar/usecase/suggest.go b/apps/backend/internal/module/radar/usecase/suggest.go index 3f39cab..c0fde7f 100644 --- a/apps/backend/internal/module/radar/usecase/suggest.go +++ b/apps/backend/internal/module/radar/usecase/suggest.go @@ -40,60 +40,61 @@ func productSuggestPrompt(p *ProductContextSnapshot, limit int) string { } /* -suggestPrompt 用服務檔案組建議關鍵字的提示。 +suggestPrompt 組建議關鍵字的提示。 -素材全部來自使用者自己填的服務檔案:服務項目、價格區間、案例、地區、禁語。 -沒有服務檔案就不呼叫 AI(見 SuggestWatchTerms)—— 沒有依據的建議只是猜測, -而使用者會把它當成系統的判斷。 +有服務檔案就帶服務項目、價格、案例、地區、禁語;沒有也能給通用求助短詞。 */ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string { var b strings.Builder - b.WriteString("你是台灣本地服務業的行銷助理。根據以下服務檔案,提出可用於社群平台搜尋的關鍵字,") + b.WriteString("你是台灣本地服務業的行銷助理。提出可用於社群平台搜尋的關鍵字,") b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n") - - b.WriteString("服務項目:\n") - for _, s := range p.Services { - b.WriteString("- " + s.Name) - if s.PriceMin > 0 || s.PriceMax > 0 { - b.WriteString(fmt.Sprintf("(價格區間 %.0f–%.0f %s)", s.PriceMin, s.PriceMax, s.Currency)) - } - b.WriteString("\n") - } - - if len(p.ServiceAreas) > 0 { - labels := make([]string, 0, len(p.ServiceAreas)) - for _, code := range p.ServiceAreas { - if label := domain.ServiceAreaLabel(code); label != "" { - labels = append(labels, label) - } - } - b.WriteString("服務地區:" + strings.Join(labels, "、") + "\n") - } - if p.RemoteOk { - b.WriteString("可遠端服務。\n") - } - if len(p.Cases) > 0 { - b.WriteString("代表案例:\n") - for _, c := range p.Cases { - b.WriteString("- " + c.Title) - if c.Summary != "" { - b.WriteString(":" + c.Summary) + if p == nil { + b.WriteString("使用者尚未填服務檔案。請產出台灣 Threads 上常見的求助/求推薦短搜尋詞。\n") + } else { + b.WriteString("服務項目:\n") + for _, s := range p.Services { + b.WriteString("- " + s.Name) + if s.PriceMin > 0 || s.PriceMax > 0 { + b.WriteString(fmt.Sprintf("(價格區間 %.0f–%.0f %s)", s.PriceMin, s.PriceMax, s.Currency)) } b.WriteString("\n") } - } - if len(p.Faq) > 0 { - b.WriteString("客戶常問:\n") - for _, f := range p.Faq { - b.WriteString("- " + f.Question + "\n") + + if len(p.ServiceAreas) > 0 { + labels := make([]string, 0, len(p.ServiceAreas)) + for _, code := range p.ServiceAreas { + if label := domain.ServiceAreaLabel(code); label != "" { + labels = append(labels, label) + } + } + b.WriteString("服務地區:" + strings.Join(labels, "、") + "\n") + } + if p.RemoteOk { + b.WriteString("可遠端服務。\n") + } + if len(p.Cases) > 0 { + b.WriteString("代表案例:\n") + for _, c := range p.Cases { + b.WriteString("- " + c.Title) + if c.Summary != "" { + b.WriteString(":" + c.Summary) + } + b.WriteString("\n") + } + } + if len(p.Faq) > 0 { + b.WriteString("客戶常問:\n") + for _, f := range p.Faq { + b.WriteString("- " + f.Question + "\n") + } + } + if len(p.Forbidden) > 0 { + // 禁語是回覆生成的硬性過濾詞,順手也不該出現在關鍵字裡。 + b.WriteString("不可使用的字詞:" + strings.Join(p.Forbidden, "、") + "\n") + } + if p.ToneNote != "" { + b.WriteString("語氣備註:" + p.ToneNote + "\n") } - } - if len(p.Forbidden) > 0 { - // 禁語是回覆生成的硬性過濾詞,順手也不該出現在關鍵字裡。 - b.WriteString("不可使用的字詞:" + strings.Join(p.Forbidden, "、") + "\n") - } - if p.ToneNote != "" { - b.WriteString("語氣備註:" + p.ToneNote + "\n") } if len(extra) > 0 { // 既有痛點關鍵字工具的產出當素材,不另建第二套關鍵字引擎(T514 決策)。 @@ -123,10 +124,11 @@ type PainTermSource interface { } /* -SuggestWatchTerms 依服務檔案回關鍵字建議(RW-03)。 +SuggestWatchTerms 回關鍵字建議(RW-03)。 -不自動寫入任何 watch:使用者逐條採用才有意義,也才看得懂每個詞是為什麼在那裡。 -計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5),不新增第五個 meter。 +服務檔案是加分項,不是門檻。AI 不可用時改走通用短詞,避免「建議關鍵字」變成下一扇牆。 +不自動寫入任何 watch:使用者逐條採用才有意義。 +計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5);fallback 不扣點。 */ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) { if ownerUID <= 0 { @@ -140,15 +142,12 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i } profile, err := s.Repo.GetServiceProfile(ctx, ownerUID) - if err != nil { - if errors.Is(err, domain.ErrNotFound) { - return nil, fmt.Errorf( - "%w: service profile required before suggesting keywords; fill in /api/v1/radar/service-profile first", - domain.ErrValidation, - ) - } + if err != nil && !errors.Is(err, domain.ErrNotFound) { return nil, err } + if errors.Is(err, domain.ErrNotFound) { + profile = nil + } var extra []string if s.PainTerms != nil { @@ -166,18 +165,69 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i } defer charge.Settle(ctx, &err) - raw, err := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra)) - if err != nil { - return nil, err + raw, aiErr := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra)) + var out []domain.WatchTermSuggestion + if aiErr == nil { + out = domain.CleanSuggestions(parseSuggestions(raw), limit) + } else { + logx.Errorf("radar suggest: AI unavailable uid=%d: %v; using fallback", ownerUID, aiErr) } - out := domain.CleanSuggestions(parseSuggestions(raw), limit) if len(out) == 0 { - // 空清單會被讀成「你的服務沒有關鍵字可監控」,那是錯的訊息。 + out = genericSuggestFallback(profile, extra, limit) + } + if len(out) == 0 { + if aiErr != nil { + return nil, aiErr + } return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation) } + if aiErr != nil { + // 沒真正用到模型:退點,避免「系統自己給的詞還收一次」。 + charge.Release(ctx) + } return out, nil } +func genericSuggestFallback(p *domain.ServiceProfile, extra []string, limit int) []domain.WatchTermSuggestion { + raw := make([]domain.WatchTermSuggestion, 0, limit+8) + add := func(term, reason, usage string) { + term = strings.TrimSpace(term) + reason = strings.TrimSpace(reason) + if term == "" || reason == "" { + return + } + raw = append(raw, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: usage}) + } + if p != nil { + for _, item := range p.Services { + name := strings.TrimSpace(item.Name) + if name == "" { + continue + } + for _, term := range domain.SearchableTermVariants(name, true) { + add(term, "依你填的服務「"+name+"」找正在問的人", domain.SuggestUsageInclude) + } + } + } + for _, term := range extra { + basis := strings.TrimSpace(term) + if basis == "" { + continue + } + for _, v := range domain.SearchableTermVariants(basis, true) { + add(v, "依你已有的痛點詞「"+basis+"」", domain.SuggestUsageInclude) + } + } + add("求推薦", "台灣 Threads 上找服務最常這樣問", domain.SuggestUsageInclude) + add("有人知道", "口語求助句,能找到正在發問的人", domain.SuggestUsageInclude) + add("求建議", "正在比較或猶豫的人常這樣寫", domain.SuggestUsageInclude) + add("哪裡找", "明確在找店家或服務的人", domain.SuggestUsageInclude) + add("怎麼辦", "遇到問題在求助的人常這樣寫", domain.SuggestUsageInclude) + add("徵才", "招募文不是客人", domain.SuggestUsageExclude) + add("接案", "同業供給文,不是需求", domain.SuggestUsageExclude) + return domain.CleanSuggestions(raw, limit) +} + // SuggestProductWatchTerms suggests demand terms from a paired Brand/Product // snapshot. If the AI provider is unavailable, the catalog's own structured // fields are used as a deterministic, traceable fallback. diff --git a/apps/backend/internal/module/radar/usecase/suggest_test.go b/apps/backend/internal/module/radar/usecase/suggest_test.go index 182ce7e..46b974c 100644 --- a/apps/backend/internal/module/radar/usecase/suggest_test.go +++ b/apps/backend/internal/module/radar/usecase/suggest_test.go @@ -119,21 +119,23 @@ func TestSuggestDoesNotCreateWatches(t *testing.T) { } } -// 沒有服務檔案就沒有依據,寧可明確拒絕也不要憑空猜關鍵字。 -func TestSuggestRequiresServiceProfile(t *testing.T) { +func TestSuggestWorksWithoutServiceProfile(t *testing.T) { svc := New(repository.NewMemory()) ai := &stubAI{reply: suggestReply} svc.AI = ai - _, err := svc.SuggestWatchTerms(context.Background(), 42, 0) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation", err) + list, err := svc.SuggestWatchTerms(context.Background(), 42, 0) + if err != nil { + t.Fatalf("suggest without profile: %v", err) } - if !strings.Contains(err.Error(), "service-profile") { - t.Fatalf("error must point at the service profile, got %q", err) + if len(list) == 0 { + t.Fatal("want generic suggestions when profile is missing") } - if ai.calls != 0 { - t.Fatal("AI was called without a service profile") + if ai.calls != 1 { + t.Fatalf("AI calls = %d, want 1", ai.calls) + } + if !strings.Contains(ai.lastPrompt, "尚未填服務檔案") { + t.Fatal("prompt should say the profile is missing instead of inventing one") } } @@ -196,22 +198,29 @@ func TestSuggestToleratesProseAroundJSON(t *testing.T) { } } -// 解析不出任何東西時要報錯:空清單會被讀成「你的服務沒有關鍵字可監控」。 -func TestSuggestFailsLoudlyOnUnusableReply(t *testing.T) { +// 模型回空話時改走通用短詞,不要讓「建議關鍵字」整顆按鈕壞掉。 +func TestSuggestFallsBackOnUnusableReply(t *testing.T) { svc, _, ctx := suggestService(t, "我不知道要建議什麼") - _, err := svc.SuggestWatchTerms(ctx, 42, 0) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation", err) + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("unusable AI reply should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") } } -func TestSuggestSurfacesAIFailure(t *testing.T) { +func TestSuggestFallsBackWhenAIFails(t *testing.T) { svc, ai, ctx := suggestService(t, "") ai.err = errors.New("provider down") - if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil { - t.Fatal("AI failure was swallowed") + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("AI failure should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") } } @@ -262,34 +271,48 @@ func TestSuggestRecordsAiCopyUsageWithRadarSource(t *testing.T) { } } -// AI 失敗要退點:扣了點卻沒拿到東西是最難解釋的帳。 +// AI 失敗改走 fallback:使用者仍拿到詞,但沒真正呼叫模型,所以要退點。 func TestSuggestReleasesCreditWhenAIFails(t *testing.T) { svc, ai, ctx := suggestService(t, "") ai.err = errors.New("provider down") usage := platformUsage(42) svc.Usage = usage - if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil { - t.Fatal("AI failure was swallowed") + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("AI failure should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") } events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0) if err != nil { t.Fatalf("list events: %v", err) } if len(events) != 0 { - t.Fatalf("charged %d events for a failed call", len(events)) + t.Fatalf("charged %d events for a fallback call", len(events)) } } -func TestSuggestWithoutAIClientAsksForKey(t *testing.T) { +func TestSuggestWithoutAIClientUsesFallback(t *testing.T) { svc := New(repository.NewMemory()) ctx := context.Background() - if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { - t.Fatalf("seed profile: %v", err) - } - _, err := svc.SuggestWatchTerms(ctx, 42, 0) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation pointing at the AI key", err) + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("missing AI client should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") + } + found := false + for _, item := range list { + if item.Term == "求推薦" { + found = true + break + } + } + if !found { + t.Fatalf("fallback missing 求推薦: %+v", list) } } diff --git a/apps/backend/internal/module/radar/usecase/sweep_run.go b/apps/backend/internal/module/radar/usecase/sweep_run.go index c5a8e4d..4a98640 100644 --- a/apps/backend/internal/module/radar/usecase/sweep_run.go +++ b/apps/backend/internal/module/radar/usecase/sweep_run.go @@ -61,10 +61,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s // for regional/freshness context when present. profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID) } else { - profile, err = s.Repo.GetServiceProfile(ctx, ownerUID) - if err != nil { - return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation) - } + profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID) } // Resume: if a sweep already exists for this job, reuse it. diff --git a/apps/backend/internal/module/radar/usecase/sweep_schedule.go b/apps/backend/internal/module/radar/usecase/sweep_schedule.go index 0026152..b323688 100644 --- a/apps/backend/internal/module/radar/usecase/sweep_schedule.go +++ b/apps/backend/internal/module/radar/usecase/sweep_schedule.go @@ -31,54 +31,62 @@ func (f SweepJobSchedulerFunc) ScheduleRadarSweep(ctx context.Context, ownerUID return f(ctx, ownerUID, watchID, runAt) } -// ScheduleDailySweeps 在已過當日 UTC 22:00 時,為每個 active watch 建一筆 radar_sweep Job。 +// ScheduleDailySweeps 為每個 active watch 補齊「今天(台北)已到期」的時段 Job。 // -// 呼叫端必須已持有 worker maintenance Redis lock(value = workerID), -// 本函式本身不做分散式鎖;未過 22:00 時回 0 且不建 Job。 -// -// 回傳建立(或已存在而回傳)的 job 數。 +// 預設時段是台北 06:00(等同舊的 UTC 22:00)。會員可在 /radar/schedule 多選時段。 +// 呼叫端必須已持有 worker maintenance Redis lock。回傳建立(或已存在)的 slot 數。 func (s *Service) ScheduleDailySweeps(ctx context.Context, now time.Time) (int, error) { if s.SweepJobs == nil { return 0, fmt.Errorf("%w: sweep job scheduler not configured", domain.ErrNotReady) } if now.IsZero() { - now = time.Now().UTC() - } else { - now = now.UTC() + now = time.Now() } - if !PastDailySweepSlot(now) { - return 0, nil - } - runAt := DailySweepRunAt(now) watches, err := s.Repo.ListAllActiveWatches(ctx) if err != nil { return 0, err } + hoursByOwner := map[int64][]int{} n := 0 for _, w := range watches { if w == nil || w.Status != domain.WatchActive { continue } - if _, err := s.SweepJobs.ScheduleRadarSweep(ctx, w.OwnerUID, w.ID, runAt); err != nil { - return n, fmt.Errorf("schedule watch %s owner %d: %w", w.ID, w.OwnerUID, err) + hours, ok := hoursByOwner[w.OwnerUID] + if !ok { + sch, gerr := s.GetRadarSchedule(ctx, w.OwnerUID) + if gerr != nil { + return n, fmt.Errorf("schedule owner %d: %w", w.OwnerUID, gerr) + } + hours = sch.Hours + hoursByOwner[w.OwnerUID] = hours + } + slots := domain.DueSweepSlots(now, hours) + for _, slot := range slots { + if _, err := s.SweepJobs.ScheduleRadarSweep(ctx, w.OwnerUID, w.ID, slot.RunAt); err != nil { + return n, fmt.Errorf("schedule watch %s owner %d hour %d: %w", w.ID, w.OwnerUID, slot.Hour, err) + } + n++ } - n++ } return n, nil } -// PastDailySweepSlot reports whether now is at or after today's UTC 22:00. +// PastDailySweepSlot reports whether the default Taipei 06:00 slot has started. func PastDailySweepSlot(now time.Time) bool { - now = now.UTC() - slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC) - return !now.Before(slot) + return len(domain.DueSweepSlots(now, domain.DefaultSweepHours())) > 0 } -// DailySweepRunAt returns unix-ns for today's UTC 22:00 (the slot being scheduled). +// DailySweepRunAt returns unix-ns for today's default Taipei 06:00 slot (or next if not yet). func DailySweepRunAt(now time.Time) int64 { - now = now.UTC() - slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC) + slots := domain.DueSweepSlots(now, domain.DefaultSweepHours()) + if len(slots) > 0 { + return slots[0].RunAt + } + loc := domain.TaipeiLocation() + local := now.In(loc) + slot := time.Date(local.Year(), local.Month(), local.Day(), domain.DefaultSweepHour, 0, 0, 0, loc) return slot.UnixNano() } diff --git a/apps/backend/internal/module/radar/usecase/sweep_schedule_test.go b/apps/backend/internal/module/radar/usecase/sweep_schedule_test.go index b70aaeb..1a54aa5 100644 --- a/apps/backend/internal/module/radar/usecase/sweep_schedule_test.go +++ b/apps/backend/internal/module/radar/usecase/sweep_schedule_test.go @@ -60,8 +60,8 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) { CreatedAt: now, UpdatedAt: now, }) - // Before 22:00 → no jobs - morning := time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC) + // Before Taipei 06:00 → no jobs + morning := time.Date(2026, 7, 31, 5, 0, 0, 0, domain.TaipeiLocation()) n, err := svc.ScheduleDailySweeps(ctx, morning) if err != nil { t.Fatal(err) @@ -70,8 +70,8 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) { t.Fatalf("before slot: want 0 jobs, got %d", n) } - // After 22:00 → two jobs (active only) - evening := time.Date(2026, 7, 31, 22, 5, 0, 0, time.UTC) + // After Taipei 06:00 → two jobs (active only) + evening := time.Date(2026, 7, 31, 6, 5, 0, 0, domain.TaipeiLocation()) n, err = svc.ScheduleDailySweeps(ctx, evening) if err != nil { t.Fatal(err) @@ -123,6 +123,50 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) { } } +func TestScheduleDailySweeps_MultipleHours(t *testing.T) { + ctx := context.Background() + radarMem := repository.NewMemory() + jobMem := jobRepo.NewMemory() + jobs := jobUC.New(jobMem) + svc := New(radarMem) + svc.SweepJobs = SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { + j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt) + if err != nil { + return "", err + } + return j.ID, nil + }) + now := domain.NowNano() + if err := radarMem.SaveWatch(ctx, &domain.RadarWatch{ + ID: "w-a", OwnerUID: 100, Terms: []string{"婚攝"}, Status: domain.WatchActive, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + if _, err := svc.PutRadarSchedule(ctx, 100, []int{6, 18}); err != nil { + t.Fatal(err) + } + loc := domain.TaipeiLocation() + n, err := svc.ScheduleDailySweeps(ctx, time.Date(2026, 7, 31, 7, 0, 0, 0, loc)) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("07:00 Taipei: want 1 slot, got %d", n) + } + n, err = svc.ScheduleDailySweeps(ctx, time.Date(2026, 7, 31, 18, 10, 0, 0, loc)) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("18:10 Taipei: want 2 slots, got %d", n) + } + list, _ := jobs.List(ctx, 100) + if len(list) != 2 { + t.Fatalf("want 2 stored jobs, got %d", len(list)) + } +} + func TestScheduleRadarSweep_ConcurrentNoDuplicate(t *testing.T) { ctx := context.Background() jobs := jobUC.New(jobRepo.NewMemory()) diff --git a/apps/backend/internal/module/radar/usecase/today.go b/apps/backend/internal/module/radar/usecase/today.go index d719d09..e4159bd 100644 --- a/apps/backend/internal/module/radar/usecase/today.go +++ b/apps/backend/internal/module/radar/usecase/today.go @@ -2,7 +2,6 @@ package usecase import ( "context" - "errors" "fmt" "apps/backend/internal/module/radar/domain" @@ -47,12 +46,6 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF } start, end := domain.UTCDayBounds(domain.NowNano()) - // Empty-state diagnostics:只有「真的沒建檔」才算 no_profile,其他 DB 錯誤要往上丟。 - _, profileErr := s.Repo.GetServiceProfile(ctx, ownerUID) - noProfile := errors.Is(profileErr, domain.ErrNotFound) - if profileErr != nil && !noProfile { - return nil, profileErr - } watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50}) if err != nil { return nil, err @@ -148,7 +141,7 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF if productFiltered { out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。" } else { - out.EmptyReason, out.EmptyHint = emptyReason(noProfile, len(watches), len(active), lastSwept, latestFail, start) + out.EmptyReason, out.EmptyHint = emptyReason(len(watches), len(active), lastSwept, latestFail, start) } } return out, nil @@ -173,10 +166,7 @@ func todayHasEligibleProduct(o *domain.Opportunity, f domain.OpportunityListFilt return false } -func emptyReason(noProfile bool, watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) { - if noProfile { - return "no_profile", "先完成服務檔案,雷達才能判定適不適合你的服務。" - } +func emptyReason(watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) { if watchCount == 0 { return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。" } diff --git a/apps/backend/internal/module/radar/usecase/today_test.go b/apps/backend/internal/module/radar/usecase/today_test.go index 347dc3c..ae0c8ee 100644 --- a/apps/backend/internal/module/radar/usecase/today_test.go +++ b/apps/backend/internal/module/radar/usecase/today_test.go @@ -82,7 +82,7 @@ func TestGetTodayEmptyNoProfile(t *testing.T) { if err != nil { t.Fatal(err) } - if got.EmptyReason != "no_profile" { - t.Fatalf("empty_reason = %q, want no_profile", got.EmptyReason) + if got.EmptyReason != "no_watch" { + t.Fatalf("empty_reason = %q, want no_watch", got.EmptyReason) } } diff --git a/apps/backend/internal/module/radar/usecase/watch.go b/apps/backend/internal/module/radar/usecase/watch.go index dfcedc6..158e7b7 100644 --- a/apps/backend/internal/module/radar/usecase/watch.go +++ b/apps/backend/internal/module/radar/usecase/watch.go @@ -152,7 +152,7 @@ func (s *Service) PauseWatch(ctx context.Context, ownerUID int64, id string) (*d } /* -ResumeWatch 回到 active,因此要再過一次配額與服務檔案閘:暫停期間方案可能已降級, +ResumeWatch 回到 active,因此要再過一次配額閘:暫停期間方案可能已降級, 不重驗就會讓人靠「暫停再恢復」繞過上限。 */ func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) { diff --git a/apps/backend/internal/module/radar/usecase/watch_quota.go b/apps/backend/internal/module/radar/usecase/watch_quota.go index fa6f22b..4e1d711 100644 --- a/apps/backend/internal/module/radar/usecase/watch_quota.go +++ b/apps/backend/internal/module/radar/usecase/watch_quota.go @@ -70,7 +70,7 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in } /* -assertCanActivateForWatch 是「變成 active」的兩道閘(SP-01、RW-01)。 +assertCanActivateForWatch 是「變成 active」的配額閘(RW-01)。 exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不會被算進 CountActive, 帶進來只為了在訊息與計算上表達清楚。 @@ -78,42 +78,8 @@ exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不 既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。 */ func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error { - if productWatch { - return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID) - } - hasProfile, err := s.HasServiceProfile(ctx, ownerUID) - if err != nil { - return err - } - if !hasProfile { - return fmt.Errorf( - "%w: service profile required before activating a radar watch; fill in /api/v1/radar/service-profile first", - domain.ErrValidation, - ) - } - - maxActive, err := s.MaxActiveWatches(ctx, ownerUID) - if err != nil { - return err - } - active, err := s.Repo.CountActiveWatches(ctx, ownerUID) - if err != nil { - return err - } - if exceptWatchID != "" { - if w, err := s.Repo.GetWatch(ctx, exceptWatchID); err == nil && w.Status == domain.WatchActive { - active-- - } else if err != nil && !errors.Is(err, domain.ErrNotFound) { - return err - } - } - if active >= int64(maxActive) { - return fmt.Errorf( - "%w: active watch limit reached (%d of %d on your plan); pause an existing watch or upgrade your plan", - domain.ErrValidation, active, maxActive, - ) - } - return nil + _ = productWatch + return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID) } func (s *Service) assertCanActivateQuota(ctx context.Context, ownerUID int64, exceptWatchID string) error { diff --git a/apps/backend/internal/module/radar/usecase/watch_test.go b/apps/backend/internal/module/radar/usecase/watch_test.go index 5f49452..22cedfb 100644 --- a/apps/backend/internal/module/radar/usecase/watch_test.go +++ b/apps/backend/internal/module/radar/usecase/watch_test.go @@ -237,21 +237,20 @@ func TestUpdateWatchOnlyTouchesGivenFields(t *testing.T) { } } -// SP-01:沒有服務檔案,判定沒有比對基準,所以不准有 active 訂閱。 -func TestActiveWatchRequiresServiceProfile(t *testing.T) { +// 服務檔案是選項:沒建檔也能用關鍵字開巡邏。 +func TestActiveWatchDoesNotRequireServiceProfile(t *testing.T) { svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30} ctx := context.Background() - _, err := svc.CreateWatch(ctx, 42, watchInput()) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation", err) + w, err := svc.CreateWatch(ctx, 42, watchInput()) + if err != nil { + t.Fatalf("create active without profile: %v", err) } - if !strings.Contains(err.Error(), "service-profile") { - t.Fatalf("error must point at the service profile, got %q", err) + if w.Status != domain.WatchActive { + t.Fatalf("status = %q, want active", w.Status) } - // 但可以先建成 paused 把關鍵字備好。 paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}}) if err != nil { t.Fatalf("create paused without profile: %v", err) @@ -259,15 +258,8 @@ func TestActiveWatchRequiresServiceProfile(t *testing.T) { if paused.Status != domain.WatchPaused { t.Fatalf("status = %q, want paused", paused.Status) } - // 建檔後才能開起來。 - if _, err := svc.ResumeWatch(ctx, 42, paused.ID); !errors.Is(err, domain.ErrValidation) { - t.Fatalf("resume without profile: err = %v, want ErrValidation", err) - } - if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { - t.Fatalf("upsert profile: %v", err) - } if _, err := svc.ResumeWatch(ctx, 42, paused.ID); err != nil { - t.Fatalf("resume after profile exists: %v", err) + t.Fatalf("resume without profile: %v", err) } } diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index b92c7f6..9d64738 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -1834,6 +1834,15 @@ type PublishPlaybookReq struct { Anonymous bool `json:"anonymous,optional"` } +type PutRadarScheduleReq struct { + Hours []int64 `json:"hours,optional"` +} + +type RadarSchedulePublic struct { + Hours []int64 `json:"hours"` + Timezone string `json:"timezone"` +} + type RadarSweepPublic struct { Id string `json:"id"` WatchId string `json:"watch_id"` diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9f78675..c4ec383 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -87,9 +87,9 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/layout/BellMenu.tsx b/apps/web/src/components/layout/BellMenu.tsx index 4e61c6a..67dc92c 100644 --- a/apps/web/src/components/layout/BellMenu.tsx +++ b/apps/web/src/components/layout/BellMenu.tsx @@ -47,10 +47,10 @@ export function BellMenu() { } }, [repos.notifications]); - // 全域資料變更、任務數改變或打開面板時才立即刷新。 + // 全域資料變更或任務數改變時刷新。打開面板改走 markSeen。 useEffect(() => { void loadNotifs(); - }, [revision, loadNotifs, tick, open]); + }, [revision, loadNotifs, tick]); // 關閉時低頻更新;頁面不可見時暫停,避免背景流量。 useEffect(() => { @@ -90,16 +90,27 @@ export function BellMenu() { ); } - function markAllRead() { + function markSeen() { const readAt = Date.now() * 1_000_000; setItems((current) => current.map((item) => (item.read_at ? item : { ...item, read_at: readAt }))); setUnread(0); void repos.notifications.markAllRead().then( - () => window.dispatchEvent(new Event("harbor:store")), + () => { + window.dispatchEvent(new Event("harbor:store")); + return loadNotifs(); + }, () => void loadNotifs(), ); } + function toggleOpen() { + setOpen((wasOpen) => { + const next = !wasOpen; + if (next) markSeen(); + return next; + }); + } + const preview = items.slice(0, PREVIEW); const more = Math.max(0, items.length - PREVIEW); @@ -108,7 +119,7 @@ export function BellMenu() { - ) : null} {items.length === 0 ? ( diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx index f2d6580..d0704bb 100644 --- a/apps/web/src/components/layout/SidebarNav.tsx +++ b/apps/web/src/components/layout/SidebarNav.tsx @@ -1,42 +1,61 @@ +import { useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { useFirstRun } from "../../firstRun/FirstRunContext"; import { useI18n } from "../../i18n/I18nContext"; import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav"; +import type { NavGroupKey, NavItem } from "../../lib/nav"; import { AppIcon } from "../ui/AppIcons"; export function SidebarNav() { const { pathname } = useLocation(); const { t } = useI18n(); const { active } = useFirstRun(); + const [openAdvanced, setOpenAdvanced] = useState(null); const groups = active ? navGroupedItemsByKeys(firstRunNavKeys) : navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) })); + function renderItem(item: NavItem) { + const current = isNavActive(pathname, item); + return ( + + + + + {t(item.labelKey)} + + ); + } + return ( ); } diff --git a/apps/web/src/components/radar/OpportunityDetailDrawer.tsx b/apps/web/src/components/radar/OpportunityDetailDrawer.tsx index 5300ad2..a186a03 100644 --- a/apps/web/src/components/radar/OpportunityDetailDrawer.tsx +++ b/apps/web/src/components/radar/OpportunityDetailDrawer.tsx @@ -1,22 +1,72 @@ -import type { Opportunity } from "../../domain/types"; +import { useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { IntentBand, Opportunity } from "../../domain/types"; import { useI18n } from "../../i18n/I18nContext"; import { Badge, Button } from "../ui"; +import { PrimaryProductPicker } from "./PrimaryProductPicker"; import { ProductMatchDetails } from "./ProductMatchDetails"; +import { ReplyComposer } from "./ReplyComposer"; + +/** 殼層 overflow-x: clip 會把 fixed 鎖在整頁座標;量頂欄/底欄,抽屜才不會衝過頭。 */ +function pinDrawerToChrome(el: HTMLElement) { + const header = document.querySelector(".hb-shell__header"); + const dock = document.querySelector(".hb-dock"); + const top = header?.getBoundingClientRect().height ?? 0; + const dockHidden = !dock || getComputedStyle(dock).display === "none"; + const bottom = dockHidden ? 0 : dock.getBoundingClientRect().height; + el.style.top = `${Math.round(top)}px`; + el.style.bottom = `${Math.round(bottom)}px`; +} + +const BANDS: IntentBand[] = ["high", "mid", "low"]; type Props = { opportunity: Opportunity; onClose: () => void; onAccept?: (opportunity: Opportunity) => void; onComplete?: (opportunity: Opportunity) => void; + /** 改主推產品(多產品匹配時);沒給就不顯示。 */ + onSetPrimary?: (opportunity: Opportunity, productId: string, reason: string) => void; + /** 覆寫意向分級;沒給就不顯示。 */ + onOverrideBand?: (opportunity: Opportunity, band: IntentBand) => void; busy?: boolean; }; -export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComplete, busy = false }: Props) { +export function OpportunityDetailDrawer({ + opportunity, + onClose, + onAccept, + onComplete, + onSetPrimary, + onOverrideBand, + busy = false, +}: Props) { const { t } = useI18n(); + const [overrideOpen, setOverrideOpen] = useState(false); + const drawerRef = useRef(null); const pending = (opportunity.review_state || "pending") === "pending"; const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id); - return ( -