From e4569d1c2ad443b24c1f9b0a165b71e45ba477c6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 27 Aug 2026 05:35:07 +0000 Subject: [PATCH] fix all bug --- apps/backend/cmd/init/main.go | 3 + apps/backend/cmd/worker/main.go | 2 +- apps/backend/crawler/src/server.ts | 24 +- apps/backend/generate/api/radar.api | 17 ++ .../radar/get_radar_schedule_handler.go | 20 ++ .../radar/put_radar_schedule_handler.go | 28 +++ apps/backend/internal/handler/routes.go | 20 +- .../logic/radar/get_radar_schedule_logic.go | 40 +++ apps/backend/internal/logic/radar/owner.go | 16 ++ .../logic/radar/put_radar_schedule_logic.go | 44 ++++ .../logic/radar/schedule_logic_test.go | 22 ++ .../internal/module/job/usecase/service.go | 24 +- .../module/radar/domain/repository.go | 3 + .../internal/module/radar/domain/schedule.go | 92 +++++++ .../module/radar/domain/schedule_test.go | 48 ++++ .../repository/service_profile_memory.go | 23 ++ .../radar/repository/service_profile_mongo.go | 19 ++ .../radar/usecase/candidate_prefilter.go | 45 +++- .../radar/usecase/candidate_prefilter_test.go | 15 ++ .../module/radar/usecase/service_profile.go | 34 +++ .../module/radar/usecase/sweep_schedule.go | 52 ++-- .../radar/usecase/sweep_schedule_test.go | 52 +++- apps/backend/internal/types/types.go | 9 + apps/web/src/components/layout/BellMenu.tsx | 35 ++- .../radar/OpportunityDetailDrawer.tsx | 39 ++- apps/web/src/data/live/radarRepos.ts | 22 ++ apps/web/src/data/mock/radarRepo.ts | 12 + apps/web/src/data/repos.ts | 2 + apps/web/src/domain/types.ts | 6 + apps/web/src/lib/i18n/catalog.en.ts | 16 +- apps/web/src/lib/i18n/catalog.zhTW.ts | 16 +- apps/web/src/pages/RadarWatchesPage.test.tsx | 19 ++ apps/web/src/pages/RadarWatchesPage.tsx | 52 +++- apps/web/src/pages/TodaySimpleFlow.test.tsx | 18 ++ apps/web/src/pages/studio/MentionsPanel.tsx | 2 +- apps/web/src/pages/studio/OwnPostsPanel.tsx | 2 +- apps/web/src/styles/global.css | 234 ++++++++++-------- apps/web/src/styles/layout.css | 149 ++++------- apps/web/src/styles/radar.css | 85 +++++-- apps/web/src/styles/tokens.css | 152 ++++++------ apps/web/src/styles/ui.css | 139 ++++++----- 41 files changed, 1196 insertions(+), 456 deletions(-) create mode 100644 apps/backend/internal/handler/radar/get_radar_schedule_handler.go create mode 100644 apps/backend/internal/handler/radar/put_radar_schedule_handler.go create mode 100644 apps/backend/internal/logic/radar/get_radar_schedule_logic.go create mode 100644 apps/backend/internal/logic/radar/put_radar_schedule_logic.go create mode 100644 apps/backend/internal/logic/radar/schedule_logic_test.go create mode 100644 apps/backend/internal/module/radar/domain/schedule.go create mode 100644 apps/backend/internal/module/radar/domain/schedule_test.go 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/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/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/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/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/service_profile.go b/apps/backend/internal/module/radar/usecase/service_profile.go index cb8a5f9..39b2679 100644 --- a/apps/backend/internal/module/radar/usecase/service_profile.go +++ b/apps/backend/internal/module/radar/usecase/service_profile.go @@ -76,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/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/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/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/radar/OpportunityDetailDrawer.tsx b/apps/web/src/components/radar/OpportunityDetailDrawer.tsx index 5cf1bd4..a186a03 100644 --- a/apps/web/src/components/radar/OpportunityDetailDrawer.tsx +++ b/apps/web/src/components/radar/OpportunityDetailDrawer.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +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"; @@ -6,6 +7,17 @@ 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 = { @@ -31,11 +43,30 @@ export function OpportunityDetailDrawer({ }: 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); const matches = opportunity.product_matches ?? []; - return ( -