fix frontend
This commit is contained in:
parent
ef6322b635
commit
8ef6fe470b
|
|
@ -7,6 +7,8 @@ deploy/.env
|
||||||
deploy/.env.dev
|
deploy/.env.dev
|
||||||
deploy/.env.prod
|
deploy/.env.prod
|
||||||
infra/.env
|
infra/.env
|
||||||
|
# Local runtime credentials. Commit the adjacent gateway.example.yaml only.
|
||||||
|
apps/backend/etc/gateway.yaml
|
||||||
old/deploy/.env
|
old/deploy/.env
|
||||||
old/deploy/.env.dev
|
old/deploy/.env.dev
|
||||||
old/deploy/.env.prod
|
old/deploy/.env.prod
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
# 只保留會重複打的指令;其餘直接 go / migrate。
|
# 只保留會重複打的指令;其餘直接 go / migrate。
|
||||||
|
|
||||||
.PHONY: build test test-integration gen-api tidy migrate-up migrate-down
|
.PHONY: build test test-integration gen-api tidy migrate-up migrate-down init seeder \
|
||||||
|
start stop restart crawler-install crawler crawler-start crawler-stop crawler-restart
|
||||||
|
|
||||||
|
RUNTIME_DIR ?= /tmp/haixun
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o bin/gateway .
|
go build -o bin/gateway .
|
||||||
go build -o bin/worker ./cmd/worker
|
go build -o bin/worker ./cmd/worker
|
||||||
|
go build -o bin/init ./cmd/init
|
||||||
|
go build -o bin/seeder ./cmd/seeder
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
@ -35,3 +40,44 @@ migrate-up:
|
||||||
|
|
||||||
migrate-down:
|
migrate-down:
|
||||||
migrate -path generate/database/mongo -database "$(MONGO_URL)" down 1
|
migrate -path generate/database/mongo -database "$(MONGO_URL)" down 1
|
||||||
|
|
||||||
|
init:
|
||||||
|
go run ./cmd/init -f etc/gateway.yaml
|
||||||
|
|
||||||
|
seeder:
|
||||||
|
go run ./cmd/seeder -f etc/gateway.yaml
|
||||||
|
|
||||||
|
crawler-install:
|
||||||
|
cd crawler && npm install && npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
# Requires SCOUT_CRAWLER_TOKEN equal to Scout.CrawlerToken in gateway.yaml.
|
||||||
|
crawler:
|
||||||
|
cd crawler && npm run start
|
||||||
|
|
||||||
|
# Local service lifecycle. Logs and PID files stay outside the worktree.
|
||||||
|
start: build
|
||||||
|
@mkdir -p "$(RUNTIME_DIR)"
|
||||||
|
@nohup "$(CURDIR)/bin/gateway" -f "$(CURDIR)/etc/gateway.yaml" >"$(RUNTIME_DIR)/gateway.log" 2>&1 & printf '%s\n' $$! >"$(RUNTIME_DIR)/gateway.pid"
|
||||||
|
@nohup "$(CURDIR)/bin/worker" -f "$(CURDIR)/etc/gateway.yaml" >"$(RUNTIME_DIR)/worker.log" 2>&1 & printf '%s\n' $$! >"$(RUNTIME_DIR)/worker.pid"
|
||||||
|
|
||||||
|
stop:
|
||||||
|
@for service in gateway worker; do \
|
||||||
|
pidfile="$(RUNTIME_DIR)/$$service.pid"; \
|
||||||
|
if [ -f "$$pidfile" ] && kill -0 "$$(cat "$$pidfile")" 2>/dev/null; then kill "$$(cat "$$pidfile")"; fi; \
|
||||||
|
rm -f "$$pidfile"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
restart: stop start
|
||||||
|
|
||||||
|
# The crawler token must come from the private runtime environment, never this file.
|
||||||
|
crawler-start:
|
||||||
|
@test -n "$(SCOUT_CRAWLER_TOKEN)" || (printf '%s\n' 'SCOUT_CRAWLER_TOKEN is required' >&2; exit 1)
|
||||||
|
@mkdir -p "$(RUNTIME_DIR)"
|
||||||
|
@cd crawler && SCOUT_CRAWLER_TOKEN="$(SCOUT_CRAWLER_TOKEN)" nohup npm run start >"$(RUNTIME_DIR)/crawler.log" 2>&1 & printf '%s\n' $$! >"$(RUNTIME_DIR)/crawler.pid"
|
||||||
|
|
||||||
|
crawler-stop:
|
||||||
|
@pidfile="$(RUNTIME_DIR)/crawler.pid"; \
|
||||||
|
if [ -f "$$pidfile" ] && kill -0 "$$(cat "$$pidfile")" 2>/dev/null; then kill "$$(cat "$$pidfile")"; fi; \
|
||||||
|
rm -f "$$pidfile"
|
||||||
|
|
||||||
|
crawler-restart: crawler-stop crawler-start
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Harbor Desk Phase C(go-zero)。
|
||||||
| 做 | 不做 |
|
| 做 | 不做 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| `generate/api` + `make gen-api` | 手寫 handler/routes |
|
| `generate/api` + `make gen-api` | 手寫 handler/routes |
|
||||||
| `generate/database/mongo` + `make migrate-up` | `cmd/init`、`cmd/seeder` 程式遷移 |
|
| `generate/database/mongo` + `make migrate-up` | 在 gateway 啟動時隱式建 index/seed |
|
||||||
| `monc` + `CacheRedis` | 自己半套 mongo cache |
|
| `monc` + `CacheRedis` | 自己半套 mongo cache |
|
||||||
| `internal/logic` + `module/*/domain|repository|usecase` | 業務塞進 handler |
|
| `internal/logic` + `module/*/domain|repository|usecase` | 業務塞進 handler |
|
||||||
|
|
||||||
|
|
@ -16,20 +16,40 @@ Harbor Desk Phase C(go-zero)。
|
||||||
```bash
|
```bash
|
||||||
cd apps/backend
|
cd apps/backend
|
||||||
make build # gateway + worker
|
make build # gateway + worker
|
||||||
|
make start # build 後背景啟動 gateway + worker(PID/log 在 /tmp/haixun)
|
||||||
|
make stop
|
||||||
|
make restart
|
||||||
make test
|
make test
|
||||||
make gen-api # 改 .api 後
|
make gen-api # 改 .api 後
|
||||||
make migrate-up # 改 generate/database 後
|
make migrate-up # 改 generate/database 後
|
||||||
|
make init # 只建立可重複執行的 operational indexes
|
||||||
|
make seeder # 讀 etc/gateway.yaml 的 Seed.AdminPassword
|
||||||
|
make crawler-install # 安裝 dev_mode Chrome crawler 的 Playwright/Chromium
|
||||||
|
SCOUT_CRAWLER_TOKEN='...' make crawler-start
|
||||||
|
SCOUT_CRAWLER_TOKEN='...' make crawler-restart
|
||||||
make tidy
|
make tidy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Scout Chrome Crawler
|
||||||
|
|
||||||
|
`dev_mode=true` 時,Scout 只走私有 Playwright crawler,沒有同步有效 Chrome session 就會要求使用者先同步。
|
||||||
|
|
||||||
|
1. 在私有 `etc/gateway.yaml` 填 `Scout.SessionSecret`、`Scout.CrawlerEndpoint`、`Scout.CrawlerToken`。
|
||||||
|
2. 執行 `make crawler-install` 一次。
|
||||||
|
3. 執行 `SCOUT_CRAWLER_TOKEN='與 Scout.CrawlerToken 相同的值' make crawler-start`。
|
||||||
|
4. crawler 必須維持 `127.0.0.1` 私網綁定,禁止公開反向代理。
|
||||||
|
|
||||||
|
詳見 [`crawler/README.md`](crawler/README.md)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 需要 Mongo + Redis(go-zero monc / redis)
|
# 需要 Mongo + Redis(go-zero monc / redis)
|
||||||
|
# 私有設定只放在 etc/gateway.yaml(此檔不會被 git 追蹤):
|
||||||
|
# cp etc/gateway.example.yaml etc/gateway.yaml
|
||||||
|
# 填入 Mongo/Redis/MinIO 密碼與兩組不同的 JWT secret。
|
||||||
# Mongo 若有帳密(本機 infra 常見),一定要帶進 URI:
|
# Mongo 若有帳密(本機 infra 常見),一定要帶進 URI:
|
||||||
export MONGO_URI='mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin'
|
MONGO_URL='mongodb://USER:PASS@127.0.0.1:27017/haixun?authSource=admin' make migrate-up
|
||||||
export MONGO_URL="$MONGO_URI" # migrate 用
|
|
||||||
make migrate-up
|
|
||||||
go run . -f etc/gateway.yaml
|
go run . -f etc/gateway.yaml
|
||||||
# admin@haixun.local / admin123(seed,uid=1000000 → 顯示 01000000)
|
# Seed.AdminPassword 填好後執行 make seeder(uid=1000000 → 顯示 01000000)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 日誌與密碼(安全)
|
### 日誌與密碼(安全)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apps/backend/internal/config"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/conf"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
// init creates idempotent operational indexes only. It intentionally never
|
||||||
|
// inserts members or other business records.
|
||||||
|
func main() {
|
||||||
|
configFile := flag.String("f", "etc/gateway.yaml", "config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad(*configFile, &c)
|
||||||
|
c.ApplyEnv()
|
||||||
|
if c.Mongo.URI == "" || c.Mongo.Database == "" {
|
||||||
|
panic("Mongo.URI and Mongo.Database are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
client, err := mongo.Connect(ctx, options.Client().ApplyURI(c.Mongo.URI))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer client.Disconnect(context.Background())
|
||||||
|
|
||||||
|
db := client.Database(c.Mongo.Database)
|
||||||
|
indexes := map[string][]mongo.IndexModel{
|
||||||
|
"jobs": {
|
||||||
|
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}, Options: options.Index().SetName("claim_due_jobs")},
|
||||||
|
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "completed_at", Value: 1}}, Options: options.Index().SetName("purge_terminal_jobs")},
|
||||||
|
},
|
||||||
|
"studio_outbox": {
|
||||||
|
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "updated_at", Value: 1}}, Options: options.Index().SetName("worker_outbox_status_updated")},
|
||||||
|
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "updated_at", Value: -1}}, Options: options.Index().SetName("owner_outbox_updated")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for collection, models := range indexes {
|
||||||
|
if _, err := db.Collection(collection).Indexes().CreateMany(ctx, models); err != nil {
|
||||||
|
panic(fmt.Errorf("create %s indexes: %w", collection, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("database indexes initialized")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apps/backend/internal/config"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/conf"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seeder is intentionally explicit: a deployment must provide its own admin
|
||||||
|
// password rather than receiving a committed default credential.
|
||||||
|
func main() {
|
||||||
|
configFile := flag.String("f", "etc/gateway.yaml", "config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad(*configFile, &c)
|
||||||
|
c.ApplyEnv()
|
||||||
|
password := c.Seed.AdminPassword
|
||||||
|
if len(password) < 12 || strings.Contains(password, "REPLACE_WITH") {
|
||||||
|
panic("Seed.AdminPassword in gateway.yaml must be at least 12 characters")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
client, err := mongo.Connect(ctx, options.Client().ApplyURI(c.Mongo.URI))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer client.Disconnect(context.Background())
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().UnixNano()
|
||||||
|
admin := bson.M{
|
||||||
|
"uid": int64(1_000_000),
|
||||||
|
"tenant_id": "default",
|
||||||
|
"email": "admin@haixun.local",
|
||||||
|
"display_name": "Harbor Admin",
|
||||||
|
"roles": []string{"admin", "member"},
|
||||||
|
"status": "active",
|
||||||
|
"email_verified": true,
|
||||||
|
"email_verified_at": now,
|
||||||
|
"invite_code": "SEEDADMIN",
|
||||||
|
"password_hash": string(hash),
|
||||||
|
"timezone": "Asia/Taipei",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
db := client.Database(c.Mongo.Database)
|
||||||
|
_, err = db.Collection("members").UpdateOne(ctx, bson.M{"uid": admin["uid"]}, bson.M{"$setOnInsert": admin}, options.Update().SetUpsert(true))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
_, err = db.Collection("counters").UpdateOne(ctx, bson.M{"_id": "member_uid"}, bson.M{"$setOnInsert": bson.M{"seq": int64(1_000_000)}}, options.Update().SetUpsert(true))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("seeded admin %s\n", strings.TrimSpace(admin["email"].(string)))
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apps/backend/internal/config"
|
"apps/backend/internal/config"
|
||||||
|
redislock "apps/backend/internal/lib/redislock"
|
||||||
"apps/backend/internal/module/ai"
|
"apps/backend/internal/module/ai"
|
||||||
appnotifRepo "apps/backend/internal/module/appnotif/repository"
|
appnotifRepo "apps/backend/internal/module/appnotif/repository"
|
||||||
appnotifUC "apps/backend/internal/module/appnotif/usecase"
|
appnotifUC "apps/backend/internal/module/appnotif/usecase"
|
||||||
|
|
@ -23,6 +24,7 @@ import (
|
||||||
jobUC "apps/backend/internal/module/job/usecase"
|
jobUC "apps/backend/internal/module/job/usecase"
|
||||||
memberDomain "apps/backend/internal/module/member/domain"
|
memberDomain "apps/backend/internal/module/member/domain"
|
||||||
memberRepo "apps/backend/internal/module/member/repository"
|
memberRepo "apps/backend/internal/module/member/repository"
|
||||||
|
scoutDomain "apps/backend/internal/module/scout/domain"
|
||||||
scoutRepo "apps/backend/internal/module/scout/repository"
|
scoutRepo "apps/backend/internal/module/scout/repository"
|
||||||
scoutUC "apps/backend/internal/module/scout/usecase"
|
scoutUC "apps/backend/internal/module/scout/usecase"
|
||||||
studioPublish "apps/backend/internal/module/studio/publish"
|
studioPublish "apps/backend/internal/module/studio/publish"
|
||||||
|
|
@ -51,10 +53,17 @@ func main() {
|
||||||
var c config.Config
|
var c config.Config
|
||||||
conf.MustLoad(*configFile, &c)
|
conf.MustLoad(*configFile, &c)
|
||||||
c.ApplyEnv()
|
c.ApplyEnv()
|
||||||
|
if err := c.ValidateProductionDependencies(); err != nil {
|
||||||
|
logx.Must(err)
|
||||||
|
}
|
||||||
|
|
||||||
workerID := c.Worker.ID
|
workerID := c.Worker.ID
|
||||||
if workerID == "" || workerID == "worker-1" || workerID == "worker-local-1" {
|
if workerID == "" || workerID == "worker-1" || workerID == "worker-local-1" || workerID == "demo-worker-1" {
|
||||||
workerID = "demo-worker-1"
|
host, _ := os.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
host = "worker"
|
||||||
|
}
|
||||||
|
workerID = fmt.Sprintf("%s-%d", host, os.Getpid())
|
||||||
}
|
}
|
||||||
interval := time.Duration(c.Worker.PollIntervalMs) * time.Millisecond
|
interval := time.Duration(c.Worker.PollIntervalMs) * time.Millisecond
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
|
|
@ -65,19 +74,15 @@ func main() {
|
||||||
jobs := jobUC.New(jobRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
|
jobs := jobUC.New(jobRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
|
||||||
jobs.Notifier = appN
|
jobs.Notifier = appN
|
||||||
|
|
||||||
secret := c.Auth.AccessSecret
|
|
||||||
if secret == "" {
|
|
||||||
secret = "dev-token-secret"
|
|
||||||
}
|
|
||||||
var provider threadsDomain.Provider
|
var provider threadsDomain.Provider
|
||||||
if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
|
if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
|
||||||
provider = threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)
|
provider = threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)
|
||||||
logx.Info("worker threads: meta provider")
|
logx.Info("worker threads: meta provider")
|
||||||
} else {
|
} else {
|
||||||
provider = &threadsProv.FakeProvider{}
|
provider = threadsProv.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
|
||||||
logx.Info("worker threads: fake provider")
|
logx.Info("worker threads: Threads operations disabled until credentials are configured")
|
||||||
}
|
}
|
||||||
threadsSvc := threadsUC.New(threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), provider, secret)
|
threadsSvc := threadsUC.New(threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), provider, c.Auth.AccessSecret)
|
||||||
threadsSvc.Renew = jobs
|
threadsSvc.Renew = jobs
|
||||||
|
|
||||||
// usage + AI keys(與 gateway 對齊,persona LLM 需真 key)
|
// usage + AI keys(與 gateway 對齊,persona LLM 需真 key)
|
||||||
|
|
@ -89,21 +94,15 @@ func main() {
|
||||||
PlatformOpenCode: c.Platform.OpenCodeKey,
|
PlatformOpenCode: c.Platform.OpenCodeKey,
|
||||||
PlatformExa: c.Platform.ExaKey,
|
PlatformExa: c.Platform.ExaKey,
|
||||||
}
|
}
|
||||||
if keyRes.PlatformAI == "" && keyRes.PlatformXAI == "" && keyRes.PlatformOpenCode == "" {
|
|
||||||
keyRes.PlatformAI = "fake-platform-ai"
|
|
||||||
logx.Info("worker usage: no platform AI keys — fake-platform-ai for quota only")
|
|
||||||
}
|
|
||||||
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
|
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
|
||||||
aiRegistry := ai.NewRegistry()
|
aiRegistry := ai.NewRegistry()
|
||||||
|
|
||||||
// Outbox/scout 發文:有 Meta 憑證則真 Threads Graph,否則 Fake(單元測試用)
|
|
||||||
var pub studioPublish.Transport
|
var pub studioPublish.Transport
|
||||||
if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
|
if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
|
||||||
pub = studioPublish.NewMeta("https://graph.threads.net")
|
pub = studioPublish.NewMeta("https://graph.threads.net")
|
||||||
logx.Info("worker studio: meta publish transport (real Threads)")
|
logx.Info("worker studio: meta publish transport (real Threads)")
|
||||||
} else {
|
} else {
|
||||||
pub = studioPublish.NewFake()
|
pub = studioPublish.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
|
||||||
logx.Info("worker studio: fake publish transport (no Threads app credentials)")
|
|
||||||
}
|
}
|
||||||
studio := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
|
studio := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
|
||||||
studio.Accounts = threadsSvc
|
studio.Accounts = threadsSvc
|
||||||
|
|
@ -118,8 +117,9 @@ func main() {
|
||||||
|
|
||||||
scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
|
scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
|
||||||
scoutSvc.Settings = &workerDevMode{Members: members}
|
scoutSvc.Settings = &workerDevMode{Members: members}
|
||||||
scoutSvc.Transport = pub
|
scoutSvc.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey)
|
||||||
scoutSvc.Accounts = threadsSvc
|
scoutSvc.SessionSecret = c.Scout.SessionSecret
|
||||||
|
scoutSvc.Crawler = scoutUC.NewHTTPCrawlerProvider(c.Scout.CrawlerEndpoint, c.Scout.CrawlerToken)
|
||||||
studio.Crawler = scoutSvc
|
studio.Crawler = scoutSvc
|
||||||
// worker 執行 job 本體,不經 API 再入列
|
// worker 執行 job 本體,不經 API 再入列
|
||||||
studio.Jobs = nil
|
studio.Jobs = nil
|
||||||
|
|
@ -133,6 +133,7 @@ func main() {
|
||||||
defer tick.Stop()
|
defer tick.Stop()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
outboxLock := redislock.New(c.CacheRedis[0].RedisConf, "haixun:worker:outbox", workerID, 60*time.Second)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sig:
|
case <-sig:
|
||||||
|
|
@ -183,17 +184,31 @@ func main() {
|
||||||
} else {
|
} else {
|
||||||
logx.Infof("worker %s play_generate_script %s ok", workerID, j.ID)
|
logx.Infof("worker %s play_generate_script %s ok", workerID, j.ID)
|
||||||
}
|
}
|
||||||
|
case jobDomain.TemplateScoutScan:
|
||||||
|
if err := runScoutScan(ctx, jobs, scoutSvc, j); err != nil {
|
||||||
|
logx.Errorf("worker %s scout scan %s failed: %v", workerID, j.ID, err)
|
||||||
|
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID)
|
logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID)
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType)
|
_, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2) outbox due steps
|
// 2) One worker owns an outbox tick. The Redis value is workerID and
|
||||||
n, err := studio.ProcessDueSteps(ctx, 0)
|
// release is compare-and-delete, so a departing worker cannot unlock a
|
||||||
if err != nil {
|
// successor's lease.
|
||||||
logx.Errorf("worker %s outbox tick: %v", workerID, err)
|
if locked, lerr := outboxLock.Acquire(ctx); lerr != nil {
|
||||||
} else if n > 0 {
|
logx.Errorf("worker %s outbox lock: %v", workerID, lerr)
|
||||||
logx.Infof("worker %s outbox published %d step(s)", workerID, n)
|
} else if locked {
|
||||||
|
n, err := studio.ProcessDueSteps(ctx, 0)
|
||||||
|
if rerr := outboxLock.Release(ctx); rerr != nil {
|
||||||
|
logx.Errorf("worker %s outbox unlock: %v", workerID, rerr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logx.Errorf("worker %s outbox tick: %v", workerID, err)
|
||||||
|
} else if n > 0 {
|
||||||
|
logx.Infof("worker %s outbox published %d step(s)", workerID, n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 3) 終態任務超過 2 天自動清除
|
// 3) 終態任務超過 2 天自動清除
|
||||||
if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil {
|
if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil {
|
||||||
|
|
@ -205,6 +220,25 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Service, j *jobDomain.Job) error {
|
||||||
|
var brief scoutDomain.RunBrief
|
||||||
|
if err := json.Unmarshal([]byte(j.Payload), &brief); err != nil {
|
||||||
|
return fmt.Errorf("invalid scout scan payload: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
posts, err := scout.RunScanFromBrief(ctx, j.OwnerUID, &brief)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已寫入 %d 筆候選", len(posts))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func runTokenRenew(ctx context.Context, jobs *jobUC.Service, threads *threadsUC.Service, j *jobDomain.Job) error {
|
func runTokenRenew(ctx context.Context, jobs *jobUC.Service, threads *threadsUC.Service, j *jobDomain.Job) error {
|
||||||
if j.RefID == "" {
|
if j.RefID == "" {
|
||||||
return fmt.Errorf("缺少帳號 ref_id")
|
return fmt.Errorf("缺少帳號 ref_id")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Scout Chrome Crawler
|
||||||
|
|
||||||
|
Private Playwright service for Scout `dev_mode=true`. It is not a public API.
|
||||||
|
It binds to `127.0.0.1` only, requires a bearer token, and receives decrypted
|
||||||
|
Chrome storage state only from the backend worker/gateway process.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend/crawler
|
||||||
|
npm install
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
Set the same private token in the local-only `apps/backend/etc/gateway.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Scout:
|
||||||
|
SessionSecret: "a dedicated long random secret"
|
||||||
|
CrawlerEndpoint: "http://127.0.0.1:8891"
|
||||||
|
CrawlerToken: "a different long random token"
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the crawler with the matching token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend/crawler
|
||||||
|
SCOUT_CRAWLER_TOKEN='the CrawlerToken value' npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: `SCOUT_CRAWLER_PORT=8891` changes the local port.
|
||||||
|
|
||||||
|
## Runtime Rules
|
||||||
|
|
||||||
|
- Never expose this port through nginx, Docker host publishing, or the public internet.
|
||||||
|
- Never place `CrawlerToken`, `SessionSecret`, or storage state in a tracked file or log.
|
||||||
|
- `dev_mode=false` does not use this service; it uses the configured API provider.
|
||||||
|
- `dev_mode=true` requires a freshly synchronized Chrome session. Missing or expired sessions fail with a user-actionable error.
|
||||||
|
- The service supports `/v1/threads/search` and `/v1/threads/resolve`; both require `Authorization: Bearer <CrawlerToken>`.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
1. Start Mongo, Redis, gateway, worker, and this crawler service.
|
||||||
|
2. In Harbor Desk settings, enable dev mode and synchronize Chrome Threads session.
|
||||||
|
3. Start a Scout run. The worker chooses crawler mode and contacts only `CrawlerEndpoint`.
|
||||||
|
4. If Threads redirects to login, sync the session again. No fallback to API occurs in dev mode.
|
||||||
|
|
@ -0,0 +1,547 @@
|
||||||
|
{
|
||||||
|
"name": "@harbor/scout-crawler",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@harbor/scout-crawler",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.58.0",
|
||||||
|
"tsx": "^4.20.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
|
"@esbuild/android-arm": "0.28.1",
|
||||||
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
|
"@esbuild/android-x64": "0.28.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||||
|
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "@harbor/scout-crawler",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/server.ts",
|
||||||
|
"start": "tsx src/server.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.58.0",
|
||||||
|
"tsx": "^4.20.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { chromium, type Page } from "playwright";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
|
type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number };
|
||||||
|
type ResolveRequest = { storage_state?: string; permalink?: string };
|
||||||
|
type Post = { permalink: string; author: string; text: string };
|
||||||
|
|
||||||
|
const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891);
|
||||||
|
const token = process.env.SCOUT_CRAWLER_TOKEN || "";
|
||||||
|
|
||||||
|
function isThreadsURL(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const host = new URL(value).hostname.toLowerCase();
|
||||||
|
return host === "threads.com" || host.endsWith(".threads.com") || host === "threads.net" || host.endsWith(".threads.net");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPosts(page: Page, limit: number): Promise<Post[]> {
|
||||||
|
const posts = new Map<string, Post>();
|
||||||
|
const links = page.locator('a[href*="/post/"]');
|
||||||
|
const count = Math.min(await links.count(), 50);
|
||||||
|
for (let i = 0; i < count && posts.size < limit; i++) {
|
||||||
|
const link = links.nth(i);
|
||||||
|
const href = await link.getAttribute("href").catch(() => null);
|
||||||
|
if (!href) continue;
|
||||||
|
const permalink = href.startsWith("http") ? href : `https://www.threads.com${href}`;
|
||||||
|
if (!isThreadsURL(permalink)) continue;
|
||||||
|
const author = href.match(/@([^/]+)\/post/)?.[1] || "";
|
||||||
|
const scope = link.locator("xpath=ancestor::div[position()<=6]").first();
|
||||||
|
const text = (await scope.innerText().catch(() => "")).trim();
|
||||||
|
if (text.length < 5) continue;
|
||||||
|
posts.set(permalink, { permalink, author, text: text.slice(0, 2000) });
|
||||||
|
}
|
||||||
|
return [...posts.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(storageState: string, terms: string[], limit: number): Promise<Post[]> {
|
||||||
|
const state = JSON.parse(storageState) as { cookies?: unknown[] };
|
||||||
|
if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const query = terms.filter(Boolean).join(" ").slice(0, 180);
|
||||||
|
await page.goto(`https://www.threads.com/search?q=${encodeURIComponent(query)}&serp_type=default`, { waitUntil: "domcontentloaded", timeout: 45_000 });
|
||||||
|
const body = await page.locator("body").innerText().catch(() => "");
|
||||||
|
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
|
||||||
|
await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
|
||||||
|
await page.mouse.wheel(0, 900);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const posts = await readPosts(page, limit);
|
||||||
|
await context.close();
|
||||||
|
return posts;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortcodeFromPermalink(permalink: string): string {
|
||||||
|
return permalink.match(/\/post\/([^/?]+)/)?.[1] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMediaID(value: unknown, shortcode: string): string {
|
||||||
|
if (!value || typeof value !== "object") return "";
|
||||||
|
if (Array.isArray(value)) { for (const item of value) { const id = findMediaID(item, shortcode); if (id) return id; } return ""; }
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const code = String(record.code || record.shortcode || "");
|
||||||
|
const id = String(record.id || record.pk || record.media_id || "");
|
||||||
|
if (code === shortcode && /^\d{10,}$/.test(id)) return id;
|
||||||
|
for (const child of Object.values(record)) { const found = findMediaID(child, shortcode); if (found) return found; }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMediaIDInText(raw: string, shortcode: string): string {
|
||||||
|
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(`(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["'][\\s\\S]{0,800}?(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})`, "i"),
|
||||||
|
new RegExp(`(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})[\\s\\S]{0,800}?(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["']`, "i"),
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = raw.match(pattern);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve(storageState: string, permalink: string): Promise<string> {
|
||||||
|
if (!isThreadsURL(permalink)) throw new Error("invalid Threads permalink");
|
||||||
|
const state = JSON.parse(storageState) as { cookies?: unknown[] };
|
||||||
|
if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");
|
||||||
|
const shortcode = shortcodeFromPermalink(permalink);
|
||||||
|
if (!shortcode) throw new Error("permalink has no Threads shortcode");
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
let mediaID = "";
|
||||||
|
const responseReads: Promise<void>[] = [];
|
||||||
|
page.on("response", async (response) => {
|
||||||
|
if (mediaID || !/graphql|threads|instagram/.test(response.url())) return;
|
||||||
|
responseReads.push((async () => {
|
||||||
|
try {
|
||||||
|
const raw = await response.text();
|
||||||
|
if (!mediaID) {
|
||||||
|
try { mediaID = findMediaID(JSON.parse(raw), shortcode); } catch { /* non-JSON response */ }
|
||||||
|
}
|
||||||
|
if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 });
|
||||||
|
const body = await page.locator("body").innerText().catch(() => "");
|
||||||
|
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await Promise.allSettled(responseReads);
|
||||||
|
if (!mediaID) {
|
||||||
|
mediaID = findMediaIDInText(await page.content(), shortcode);
|
||||||
|
}
|
||||||
|
await context.close();
|
||||||
|
if (!mediaID) throw new Error("Threads media ID could not be resolved")
|
||||||
|
return mediaID;
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required");
|
||||||
|
|
||||||
|
createServer(async (req, res) => {
|
||||||
|
const path = new URL(req.url || "/", "http://127.0.0.1").pathname;
|
||||||
|
if (req.method !== "POST" || (path !== "/v1/threads/search" && path !== "/v1/threads/resolve")) {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.headers.authorization !== `Bearer ${token}`) {
|
||||||
|
res.writeHead(401).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await new Promise<string>((resolve, reject) => {
|
||||||
|
let raw = "";
|
||||||
|
req.setEncoding("utf8");
|
||||||
|
req.on("data", (chunk) => { raw += chunk; if (raw.length > 300_000) req.destroy(); });
|
||||||
|
req.on("end", () => resolve(raw));
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (path === "/v1/threads/resolve") {
|
||||||
|
const input = JSON.parse(body) as ResolveRequest;
|
||||||
|
const mediaID = await resolve(String(input.storage_state || ""), String(input.permalink || ""));
|
||||||
|
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID }));
|
||||||
|
} else {
|
||||||
|
const input = JSON.parse(body) as SearchRequest;
|
||||||
|
const posts = await search(String(input.storage_state || ""), Array.isArray(input.terms) ? input.terms.slice(0, 12) : [], Math.min(Math.max(Number(input.limit) || 10, 1), 30));
|
||||||
|
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ posts }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(422, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" }));
|
||||||
|
}
|
||||||
|
}).listen(port, "127.0.0.1");
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# 巡樓 gateway — 唯一主設定檔(A-02)
|
# 巡樓 gateway — 可提交的設定範本。
|
||||||
# Secret 請用環境變數覆寫。
|
# 複製為 gateway.yaml 後填入私有密鑰;gateway.yaml 已被 .gitignore 排除。
|
||||||
Name: haixun-gateway
|
Name: haixun-gateway
|
||||||
Host: 0.0.0.0
|
Host: 0.0.0.0
|
||||||
Port: 8888
|
Port: 8888
|
||||||
|
|
@ -28,34 +28,46 @@ Mongo:
|
||||||
Database: "haixun"
|
Database: "haixun"
|
||||||
|
|
||||||
Auth:
|
Auth:
|
||||||
AccessSecret: "change-me-access-secret"
|
# 至少 32 bytes,且不得與 RefreshSecret 相同。
|
||||||
|
AccessSecret: "REPLACE_WITH_A_LONG_RANDOM_ACCESS_SECRET"
|
||||||
AccessExpire: 86400
|
AccessExpire: 86400
|
||||||
RefreshSecret: "change-me-refresh-secret"
|
RefreshSecret: "REPLACE_WITH_A_DIFFERENT_LONG_RANDOM_REFRESH_SECRET"
|
||||||
RefreshExpire: 604800
|
RefreshExpire: 604800
|
||||||
|
|
||||||
# 平台預設 key(所有會員可用;會員在設定頁填的 BYOK 優先)
|
# 平台預設 key(所有會員可用;會員在設定頁填的 BYOK 優先)
|
||||||
# 也可用環境變數覆寫:PLATFORM_XAI_KEY / PLATFORM_OPENCODE_KEY / PLATFORM_EXA_KEY / PLATFORM_AI_KEY
|
# 也可用環境變數覆寫:PLATFORM_XAI_KEY / PLATFORM_OPENCODE_KEY / PLATFORM_EXA_KEY / PLATFORM_AI_KEY
|
||||||
Platform:
|
Platform:
|
||||||
# xAI(api.x.ai)— 人設 LLM、文案等
|
# xAI(api.x.ai)— 人設 LLM、文案等
|
||||||
XAIKey: "xai-TtmPWVAz1CdssptRetj9iOFSdSG2LZDffEOTKzIiDmVSDqUL62lVdDladlQIXAwoeHtowWraVqYuHzl1"
|
XAIKey: ""
|
||||||
# 相容舊名:XAIKey 空時會用 AIKey
|
# 相容舊名:XAIKey 空時會用 AIKey
|
||||||
AIKey: ""
|
AIKey: ""
|
||||||
# OpenCode Go(opencode.ai/zen/go)
|
# OpenCode Go(opencode.ai/zen/go)
|
||||||
OpenCodeKey: "sk-sJkJVG2Q3tQEGqgl4cCy10QCVnrHpvuzR8RuE2eTuvruXiVNromsQ7zo6db0Ssj1"
|
OpenCodeKey: ""
|
||||||
# Exa 網搜
|
# Exa 網搜
|
||||||
ExaKey: "5d9af9b2-a847-4295-a110-f666049e1073"
|
ExaKey: ""
|
||||||
ThreadsAppId: "2733369680379930"
|
ThreadsAppId: ""
|
||||||
ThreadsAppSecret: "31db4afdd8e348d75d76dee8fb9a42d3"
|
ThreadsAppSecret: ""
|
||||||
|
|
||||||
|
Scout:
|
||||||
|
# Dedicated encryption key for Chrome storage state; do not reuse JWT keys.
|
||||||
|
SessionSecret: "REPLACE_WITH_A_DEDICATED_SCOUT_SESSION_SECRET"
|
||||||
|
# Private Playwright crawler service, called only by cmd/worker in dev_mode.
|
||||||
|
CrawlerEndpoint: ""
|
||||||
|
CrawlerToken: ""
|
||||||
|
|
||||||
Worker:
|
Worker:
|
||||||
# demo-worker 預設 id(cmd/worker 若見 worker-local-1 會改成 demo-worker-1)
|
# 留空時 worker 會用 hostname + pid 產生唯一 owner id;多 worker 不可共用固定值。
|
||||||
ID: "demo-worker-1"
|
ID: ""
|
||||||
PollIntervalMs: 2000
|
PollIntervalMs: 2000
|
||||||
|
|
||||||
|
# 僅 cmd/seeder 使用,gateway/worker 不會讀取或寫入 log。
|
||||||
|
Seed:
|
||||||
|
AdminPassword: "REPLACE_WITH_A_STRONG_SEED_ADMIN_PASSWORD"
|
||||||
|
|
||||||
Bcrypt:
|
Bcrypt:
|
||||||
Cost: 10
|
Cost: 10
|
||||||
|
|
||||||
# OAuth(空則 callback 走 mock / 開發路徑)
|
# OAuth(未設定 provider 時相關 API 明確拒絕,不提供 mock 路徑)
|
||||||
OAuth:
|
OAuth:
|
||||||
GoogleClientID: ""
|
GoogleClientID: ""
|
||||||
LineClientID: ""
|
LineClientID: ""
|
||||||
|
|
@ -88,6 +100,12 @@ Mail:
|
||||||
# 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg)
|
# 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg)
|
||||||
# 遠端 dev:https://threads-tool-dev.30cm.net
|
# 遠端 dev:https://threads-tool-dev.30cm.net
|
||||||
PublicWebBase: "https://threads-tool-dev.30cm.net"
|
PublicWebBase: "https://threads-tool-dev.30cm.net"
|
||||||
|
# 瀏覽器直連 gateway 時的 allowlist;同源 proxy 不需要列在此處。
|
||||||
|
# CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin,不能使用 *。
|
||||||
|
CORSAllowedOrigins:
|
||||||
|
- "https://threads-tool-dev.30cm.net"
|
||||||
|
- "http://127.0.0.1:5173"
|
||||||
|
- "http://localhost:5173"
|
||||||
|
|
||||||
# 郵件品牌(hermes 模板;LogoURL 空 = PublicWebBase/brand-mark.jpg)
|
# 郵件品牌(hermes 模板;LogoURL 空 = PublicWebBase/brand-mark.jpg)
|
||||||
Brand:
|
Brand:
|
||||||
|
|
@ -27,15 +27,18 @@ func main() {
|
||||||
var c config.Config
|
var c config.Config
|
||||||
conf.MustLoad(*configFile, &c)
|
conf.MustLoad(*configFile, &c)
|
||||||
c.ApplyEnv()
|
c.ApplyEnv()
|
||||||
|
if err := c.ValidateProductionDependencies(); err != nil {
|
||||||
|
logx.Must(err)
|
||||||
|
}
|
||||||
|
|
||||||
// 禁止 Verbose(否則可能印 request body 含 password)
|
// 禁止 Verbose(否則可能印 request body 含 password)
|
||||||
c.RestConf.Verbose = false
|
c.RestConf.Verbose = false
|
||||||
|
|
||||||
server := rest.MustNewServer(c.RestConf,
|
server := rest.MustNewServer(c.RestConf,
|
||||||
rest.WithNotAllowedHandler(middleware.NewCorsMiddleware().Handler()),
|
rest.WithNotAllowedHandler(middleware.NewCorsMiddleware(c.CORSAllowedOrigins).Handler()),
|
||||||
)
|
)
|
||||||
defer server.Stop()
|
defer server.Stop()
|
||||||
server.Use(middleware.NewCorsMiddleware().Handle)
|
server.Use(middleware.NewCorsMiddleware(c.CORSAllowedOrigins).Handle)
|
||||||
|
|
||||||
ctx := svc.NewServiceContext(c)
|
ctx := svc.NewServiceContext(c)
|
||||||
handler.RegisterHandlers(server, ctx)
|
handler.RegisterHandlers(server, ctx)
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,9 @@ type (
|
||||||
ScoutScanReq {
|
ScoutScanReq {
|
||||||
Brief ScoutBriefPublic `json:"brief"`
|
Brief ScoutBriefPublic `json:"brief"`
|
||||||
}
|
}
|
||||||
|
ScoutScanJobData {
|
||||||
|
Job JobPublic `json:"job"`
|
||||||
|
}
|
||||||
ScoutPostPublic {
|
ScoutPostPublic {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
BrandId string `json:"brand_id,optional"`
|
BrandId string `json:"brand_id,optional"`
|
||||||
|
|
@ -259,6 +262,8 @@ type (
|
||||||
ThemeKey string `json:"theme_key,optional"`
|
ThemeKey string `json:"theme_key,optional"`
|
||||||
ThemeLabel string `json:"theme_label,optional"`
|
ThemeLabel string `json:"theme_label,optional"`
|
||||||
ScanPath string `json:"scan_path,optional"`
|
ScanPath string `json:"scan_path,optional"`
|
||||||
|
Classification string `json:"classification,optional"`
|
||||||
|
Permalink string `json:"permalink,optional"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
ScoutPostListData {
|
ScoutPostListData {
|
||||||
|
|
@ -422,7 +427,7 @@ service gateway {
|
||||||
post /brief (ScoutBriefReq) returns (ScoutBriefPublic)
|
post /brief (ScoutBriefReq) returns (ScoutBriefPublic)
|
||||||
|
|
||||||
@handler RunScan
|
@handler RunScan
|
||||||
post /scan (ScoutScanReq) returns (ScoutPostListData)
|
post /scan (ScoutScanReq) returns (ScoutScanJobData)
|
||||||
|
|
||||||
@handler ListScoutPosts
|
@handler ListScoutPosts
|
||||||
get /posts (ScoutPostListReq) returns (ScoutPostListData)
|
get /posts (ScoutPostListReq) returns (ScoutPostListData)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
[
|
||||||
|
{ "dropIndexes": "jobs", "index": "claim_due_jobs" },
|
||||||
|
{ "dropIndexes": "jobs", "index": "purge_terminal_jobs" },
|
||||||
|
{ "dropIndexes": "studio_outbox", "index": "worker_outbox_status_updated" },
|
||||||
|
{ "dropIndexes": "studio_outbox", "index": "owner_outbox_updated" }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"createIndexes": "jobs",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"key": { "status": 1, "run_after": 1, "created_at": 1 },
|
||||||
|
"name": "claim_due_jobs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": { "status": 1, "completed_at": 1 },
|
||||||
|
"name": "purge_terminal_jobs"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"createIndexes": "studio_outbox",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"key": { "status": 1, "updated_at": 1 },
|
||||||
|
"name": "worker_outbox_status_updated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": { "owner_uid": 1, "updated_at": -1 },
|
||||||
|
"name": "owner_outbox_updated"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -26,7 +27,9 @@ type Config struct {
|
||||||
RefreshSecret string
|
RefreshSecret string
|
||||||
RefreshExpire int64
|
RefreshExpire int64
|
||||||
}
|
}
|
||||||
Platform struct {
|
// CORSAllowedOrigins is an explicit browser-origin allowlist.
|
||||||
|
CORSAllowedOrigins []string `json:",optional"`
|
||||||
|
Platform struct {
|
||||||
// AIKey — legacy alias for XAIKey
|
// AIKey — legacy alias for XAIKey
|
||||||
AIKey string `json:",optional"`
|
AIKey string `json:",optional"`
|
||||||
// XAIKey — platform BYOK for xAI (api.x.ai)
|
// XAIKey — platform BYOK for xAI (api.x.ai)
|
||||||
|
|
@ -37,15 +40,25 @@ type Config struct {
|
||||||
ThreadsAppId string `json:",optional"`
|
ThreadsAppId string `json:",optional"`
|
||||||
ThreadsAppSecret string `json:",optional"`
|
ThreadsAppSecret string `json:",optional"`
|
||||||
}
|
}
|
||||||
|
// Scout keeps browser-session credentials separate from JWT and public APIs.
|
||||||
|
Scout struct {
|
||||||
|
SessionSecret string `json:",optional"`
|
||||||
|
CrawlerEndpoint string `json:",optional"`
|
||||||
|
CrawlerToken string `json:",optional"`
|
||||||
|
}
|
||||||
Worker struct {
|
Worker struct {
|
||||||
ID string `json:",default=worker-1"`
|
ID string `json:",default=worker-1"`
|
||||||
PollIntervalMs int `json:",default=2000"`
|
PollIntervalMs int `json:",default=2000"`
|
||||||
}
|
}
|
||||||
|
// Seed is used only by cmd/seeder; gateway and worker never read or log it.
|
||||||
|
Seed struct {
|
||||||
|
AdminPassword string `json:",optional"`
|
||||||
|
}
|
||||||
// Bcrypt cost (stand-alone Bcrypt.Cost)
|
// Bcrypt cost (stand-alone Bcrypt.Cost)
|
||||||
Bcrypt struct {
|
Bcrypt struct {
|
||||||
Cost int `json:",default=10"`
|
Cost int `json:",default=10"`
|
||||||
}
|
}
|
||||||
// OAuth third-party (optional; empty → mock path in callback)
|
// OAuth third-party. Empty provider config disables that provider.
|
||||||
OAuth struct {
|
OAuth struct {
|
||||||
GoogleClientID string `json:",optional"`
|
GoogleClientID string `json:",optional"`
|
||||||
LineClientID string `json:",optional"`
|
LineClientID string `json:",optional"`
|
||||||
|
|
@ -183,6 +196,9 @@ func (c *Config) ApplyEnv() {
|
||||||
if v := firstEnv("PUBLIC_API_BASE", "HAIXUN_PUBLIC_API_BASE"); v != "" {
|
if v := firstEnv("PUBLIC_API_BASE", "HAIXUN_PUBLIC_API_BASE"); v != "" {
|
||||||
c.PublicAPIBase = strings.TrimRight(v, "/")
|
c.PublicAPIBase = strings.TrimRight(v, "/")
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
|
||||||
|
c.CORSAllowedOrigins = appendUnique(c.CORSAllowedOrigins, splitCSV(v))
|
||||||
|
}
|
||||||
if v := os.Getenv("MAIL_BRAND_NAME"); v != "" {
|
if v := os.Getenv("MAIL_BRAND_NAME"); v != "" {
|
||||||
c.Brand.Name = v
|
c.Brand.Name = v
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +234,35 @@ func (c *Config) ApplyEnv() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateProductionDependencies prevents a successful-looking deployment that
|
||||||
|
// silently falls back to test credentials or fake external integrations.
|
||||||
|
func (c Config) ValidateProductionDependencies() error {
|
||||||
|
if invalidSecret(c.Auth.AccessSecret) {
|
||||||
|
return fmt.Errorf("AUTH_ACCESS_SECRET must be at least 32 bytes")
|
||||||
|
}
|
||||||
|
if invalidSecret(c.Auth.RefreshSecret) {
|
||||||
|
return fmt.Errorf("AUTH_REFRESH_SECRET must be at least 32 bytes")
|
||||||
|
}
|
||||||
|
if c.Auth.AccessSecret == c.Auth.RefreshSecret {
|
||||||
|
return fmt.Errorf("AUTH_ACCESS_SECRET and AUTH_REFRESH_SECRET must differ")
|
||||||
|
}
|
||||||
|
if len(c.CacheRedis) == 0 || strings.TrimSpace(c.CacheRedis[0].Host) == "" {
|
||||||
|
return fmt.Errorf("CacheRedis is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Mongo.URI) == "" || strings.TrimSpace(c.Mongo.Database) == "" {
|
||||||
|
return fmt.Errorf("Mongo.URI and Mongo.Database are required")
|
||||||
|
}
|
||||||
|
if len(c.CORSAllowedOrigins) == 0 {
|
||||||
|
return fmt.Errorf("CORSAllowedOrigins is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidSecret(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
return len(value) < 32 || strings.Contains(value, "REPLACE_WITH") || strings.Contains(value, "CHANGE_ME")
|
||||||
|
}
|
||||||
|
|
||||||
func firstEnv(keys ...string) string {
|
func firstEnv(keys ...string) string {
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
|
|
@ -227,6 +272,34 @@ func firstEnv(keys ...string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendUnique(existing, values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(existing)+len(values))
|
||||||
|
out := make([]string, 0, len(existing)+len(values))
|
||||||
|
for _, value := range append(existing, values...) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(v string) []string {
|
||||||
|
parts := strings.Split(v, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if part = strings.TrimSpace(part); part != "" {
|
||||||
|
out = append(out, strings.TrimRight(part, "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func parsePort(v string) int {
|
func parsePort(v string) int {
|
||||||
var p int
|
var p int
|
||||||
for _, ch := range v {
|
for _, ch := range v {
|
||||||
|
|
|
||||||
|
|
@ -401,12 +401,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
[]rest.Route{
|
[]rest.Route{
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/generate-image",
|
Path: "/upload",
|
||||||
Handler: media.GenerateImageHandler(serverCtx),
|
Handler: media.UploadHandler(serverCtx),
|
||||||
},
|
},
|
||||||
}...,
|
}...,
|
||||||
),
|
),
|
||||||
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
|
|
||||||
rest.WithPrefix("/api/v1/media"),
|
rest.WithPrefix("/api/v1/media"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -416,11 +415,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
[]rest.Route{
|
[]rest.Route{
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/upload",
|
Path: "/generate-image",
|
||||||
Handler: media.UploadHandler(serverCtx),
|
Handler: media.GenerateImageHandler(serverCtx),
|
||||||
},
|
},
|
||||||
}...,
|
}...,
|
||||||
),
|
),
|
||||||
|
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
|
||||||
rest.WithPrefix("/api/v1/media"),
|
rest.WithPrefix("/api/v1/media"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package redislock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lock is a small Redis lease. Value must uniquely identify the worker that
|
||||||
|
// owns it; Release uses a Lua compare-and-delete to avoid deleting a renewed
|
||||||
|
// lease owned by another worker.
|
||||||
|
type Lock struct {
|
||||||
|
client *redis.Redis
|
||||||
|
key string
|
||||||
|
owner string
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(conf redis.RedisConf, key, owner string, ttl time.Duration) *Lock {
|
||||||
|
return &Lock{client: redis.MustNewRedis(conf), key: key, owner: owner, ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lock) Acquire(ctx context.Context) (bool, error) {
|
||||||
|
if l == nil || l.client == nil || l.key == "" || l.owner == "" {
|
||||||
|
return false, fmt.Errorf("invalid redis lock")
|
||||||
|
}
|
||||||
|
seconds := int(l.ttl.Seconds())
|
||||||
|
if seconds < 1 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
return l.client.SetnxExCtx(ctx, l.key, l.owner, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lock) Release(ctx context.Context) error {
|
||||||
|
if l == nil || l.client == nil {
|
||||||
|
return fmt.Errorf("invalid redis lock")
|
||||||
|
}
|
||||||
|
_, err := l.client.EvalCtx(ctx, `
|
||||||
|
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call('DEL', KEYS[1])
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
`, []string{l.key}, l.owner)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -34,27 +34,12 @@ func (l *OAuthCallbackLogic) OAuthCallback(req *types.AuthOAuthCallbackReq) (res
|
||||||
if token == "" {
|
if token == "" {
|
||||||
token = req.IdToken
|
token = req.IdToken
|
||||||
}
|
}
|
||||||
if token == "" {
|
|
||||||
token = req.Code
|
|
||||||
}
|
|
||||||
// allow mock: code or token "mock:sub:email"
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, domain.ErrOAuthFailed
|
return nil, domain.ErrOAuthFailed
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(token, "mock:") && req.Code != "" && strings.HasPrefix(req.Code, "mock:") {
|
|
||||||
token = req.Code
|
|
||||||
}
|
|
||||||
subject, email, name, err = l.svcCtx.Auth.VerifyGoogleIDToken(l.ctx, token)
|
subject, email, name, err = l.svcCtx.Auth.VerifyGoogleIDToken(l.ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// dev fallback when Google not configured: treat code as mock subject
|
return nil, err
|
||||||
if req.Code != "" {
|
|
||||||
subject = provider + "_" + req.Code
|
|
||||||
email = subject + "@oauth.local"
|
|
||||||
name = provider + " user"
|
|
||||||
err = nil
|
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case domain.PlatformLine:
|
case domain.PlatformLine:
|
||||||
code := req.Code
|
code := req.Code
|
||||||
|
|
@ -63,23 +48,10 @@ func (l *OAuthCallbackLogic) OAuthCallback(req *types.AuthOAuthCallbackReq) (res
|
||||||
}
|
}
|
||||||
subject, name, email, err = l.svcCtx.Auth.LineCodeToProfile(l.ctx, code, req.RedirectUri)
|
subject, name, email, err = l.svcCtx.Auth.LineCodeToProfile(l.ctx, code, req.RedirectUri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code != "" {
|
return nil, err
|
||||||
subject = provider + "_" + code
|
|
||||||
name = provider + " user"
|
|
||||||
err = nil
|
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// generic mock oauth
|
return nil, domain.ErrOAuthFailed
|
||||||
code := req.Code
|
|
||||||
if code == "" {
|
|
||||||
code = "anon"
|
|
||||||
}
|
|
||||||
subject = provider + "_" + code
|
|
||||||
email = subject + "@oauth.local"
|
|
||||||
name = provider + " user"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m, pair, err := l.svcCtx.Auth.LoginOAuth(l.ctx, provider, subject, email, name)
|
m, pair, err := l.svcCtx.Auth.LoginOAuth(l.ctx, provider, subject, email, name)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"apps/backend/internal/svc"
|
"apps/backend/internal/svc"
|
||||||
"apps/backend/internal/types"
|
"apps/backend/internal/types"
|
||||||
|
|
@ -20,13 +22,24 @@ func NewOAuthStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OAuthS
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *OAuthStartLogic) OAuthStart(req *types.AuthOAuthProviderPath) (resp *types.AuthOAuthStartData, err error) {
|
func (l *OAuthStartLogic) OAuthStart(req *types.AuthOAuthProviderPath) (resp *types.AuthOAuthStartData, err error) {
|
||||||
p := req.Provider
|
p := strings.ToLower(strings.TrimSpace(req.Provider))
|
||||||
if p == "" {
|
if p == "" {
|
||||||
p = "google"
|
p = "google"
|
||||||
}
|
}
|
||||||
return &types.AuthOAuthStartData{
|
// The current API accepts a verified provider credential in callback; it
|
||||||
AuthorizeUrl: "https://example.com/oauth/" + p + "/authorize?state=dev",
|
// does not yet expose a state-consuming browser callback, so returning a
|
||||||
Provider: p,
|
// fabricated authorization URL would be an authentication bypass.
|
||||||
State: "dev",
|
switch p {
|
||||||
}, nil
|
case "google":
|
||||||
|
if strings.TrimSpace(l.svcCtx.Config.OAuth.GoogleClientID) == "" {
|
||||||
|
return nil, fmt.Errorf("google OAuth is not configured")
|
||||||
|
}
|
||||||
|
case "line":
|
||||||
|
if strings.TrimSpace(l.svcCtx.Config.OAuth.LineClientID) == "" || strings.TrimSpace(l.svcCtx.Config.OAuth.LineClientSecret) == "" {
|
||||||
|
return nil, fmt.Errorf("LINE OAuth is not configured")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported OAuth provider: %s", p)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("interactive OAuth start is unavailable until Redis-backed state and PKCE callback are configured")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package scout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"apps/backend/internal/middleware"
|
"apps/backend/internal/middleware"
|
||||||
"apps/backend/internal/response"
|
"apps/backend/internal/response"
|
||||||
|
|
@ -21,23 +22,24 @@ func NewRunScanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunScanLo
|
||||||
return &RunScanLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
return &RunScanLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *RunScanLogic) RunScan(req *types.ScoutScanReq) (*types.ScoutPostListData, error) {
|
func (l *RunScanLogic) RunScan(req *types.ScoutScanReq) (*types.ScoutScanJobData, error) {
|
||||||
if l.svcCtx.Scout == nil {
|
if l.svcCtx.Jobs == nil {
|
||||||
return nil, response.Biz(503, 503001, "scout not configured")
|
return nil, response.Biz(503, 503001, "jobs module not configured")
|
||||||
}
|
}
|
||||||
uid, ok := middleware.UIDFrom(l.ctx)
|
uid, ok := middleware.UIDFrom(l.ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, response.Biz(401, 401001, "missing authorization")
|
return nil, response.Biz(401, 401001, "missing authorization")
|
||||||
}
|
}
|
||||||
|
|
||||||
list, err := l.svcCtx.Scout.RunScanFromBrief(l.ctx, uid, types.BriefToDomain(&req.Brief))
|
brief := types.BriefToDomain(&req.Brief)
|
||||||
|
payload, err := json.Marshal(brief)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]types.ScoutPostPublic, 0, len(list))
|
j, err := l.svcCtx.Jobs.ScheduleScoutScan(l.ctx, uid, brief.ThemeKey, string(payload))
|
||||||
for _, p := range list {
|
if err != nil {
|
||||||
out = append(out, types.ScoutPostFromDomain(p))
|
return nil, err
|
||||||
}
|
}
|
||||||
return &types.ScoutPostListData{List: out}, nil
|
return &types.ScoutScanJobData{Job: types.JobFromModel(j)}, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,35 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// CorsMiddleware — browser CORS for Harbor Desk FE.
|
// CorsMiddleware — browser CORS for Harbor Desk FE.
|
||||||
// Note: cannot combine Access-Control-Allow-Origin: * with Allow-Credentials: true
|
// Note: cannot combine Access-Control-Allow-Origin: * with Allow-Credentials: true
|
||||||
// (browsers reject). FE uses Bearer token (no cookies), so no credentials header.
|
// (browsers reject). FE uses Bearer token (no cookies), so no credentials header.
|
||||||
type CorsMiddleware struct{}
|
type CorsMiddleware struct {
|
||||||
|
allowedOrigins map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
func NewCorsMiddleware() *CorsMiddleware {
|
func NewCorsMiddleware(origins []string) *CorsMiddleware {
|
||||||
return &CorsMiddleware{}
|
allowed := make(map[string]struct{}, len(origins))
|
||||||
|
for _, origin := range origins {
|
||||||
|
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
||||||
|
if origin != "" {
|
||||||
|
allowed[origin] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &CorsMiddleware{allowedOrigins: allowed}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
setHeader(w, r)
|
if !m.setHeader(w, r) {
|
||||||
|
writeOriginDenied(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|
@ -24,7 +40,10 @@ func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
|
||||||
func (m *CorsMiddleware) Handler() http.Handler {
|
func (m *CorsMiddleware) Handler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
setHeader(w, r)
|
if !m.setHeader(w, r) {
|
||||||
|
writeOriginDenied(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|
@ -33,16 +52,37 @@ func (m *CorsMiddleware) Handler() http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setHeader(w http.ResponseWriter, r *http.Request) {
|
func writeOriginDenied(w http.ResponseWriter) {
|
||||||
origin := r.Header.Get("Origin")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if origin == "" {
|
w.WriteHeader(http.StatusForbidden)
|
||||||
origin = "*"
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 403003, "message": "origin is not allowed", "data": nil, "error": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CorsMiddleware) setHeader(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
origin := strings.TrimRight(strings.TrimSpace(r.Header.Get("Origin")), "/")
|
||||||
|
if origin != "" {
|
||||||
|
if _, ok := m.allowedOrigins[origin]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
appendVary(w, "Origin")
|
||||||
}
|
}
|
||||||
// Echo request origin (dev-friendly). Production can whitelist later.
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
||||||
w.Header().Set("Vary", "Origin")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id, device_id")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id, device_id")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "600")
|
||||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type")
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type")
|
||||||
// Do NOT set Allow-Credentials with wildcard; FE uses Authorization header only.
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendVary(w http.ResponseWriter, value string) {
|
||||||
|
for _, existing := range w.Header().Values("Vary") {
|
||||||
|
for _, part := range strings.Split(existing, ",") {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(part), value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Add("Vary", value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCorsAllowsConfiguredOriginPreflight(t *testing.T) {
|
||||||
|
m := NewCorsMiddleware([]string{"http://10.0.0.9:5173"})
|
||||||
|
r := httptest.NewRequest(http.MethodOptions, "/api/v1/settings/ai", nil)
|
||||||
|
r.Header.Set("Origin", "http://10.0.0.9:5173")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
m.Handle(func(http.ResponseWriter, *http.Request) { t.Fatal("OPTIONS must not reach next handler") })(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://10.0.0.9:5173" {
|
||||||
|
t.Fatalf("allow origin = %q", got)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Max-Age"); got != "600" {
|
||||||
|
t.Fatalf("max age = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorsRejectsUnknownOriginWithEnvelope(t *testing.T) {
|
||||||
|
m := NewCorsMiddleware([]string{"http://10.0.0.9:5173"})
|
||||||
|
r := httptest.NewRequest(http.MethodPut, "/api/v1/settings/ai", nil)
|
||||||
|
r.Header.Set("Origin", "http://untrusted.example")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
m.Handle(func(http.ResponseWriter, *http.Request) { t.Fatal("rejected origin reached next handler") })(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
||||||
|
t.Fatalf("content type = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,15 +32,23 @@ func (m *mwRepo) FindByUID(_ context.Context, uid int64) (*domain.Member, error)
|
||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mwRepo) NextUID(context.Context) (int64, error) { return 0, nil }
|
func (m *mwRepo) NextUID(context.Context) (int64, error) { return 0, nil }
|
||||||
func (m *mwRepo) CreateMember(context.Context, *domain.Member) error { return nil }
|
func (m *mwRepo) CreateMember(context.Context, *domain.Member) error { return nil }
|
||||||
func (m *mwRepo) FindByEmail(context.Context, string) (*domain.Member, error) { return nil, domain.ErrNotFound }
|
func (m *mwRepo) FindByEmail(context.Context, string) (*domain.Member, error) {
|
||||||
func (m *mwRepo) FindByPhone(context.Context, string) (*domain.Member, error) { return nil, domain.ErrNotFound }
|
return nil, domain.ErrNotFound
|
||||||
func (m *mwRepo) UpdateMember(context.Context, *domain.Member) error { return nil }
|
}
|
||||||
|
func (m *mwRepo) FindByPhone(context.Context, string) (*domain.Member, error) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
func (m *mwRepo) FindByInviteCode(context.Context, string) (*domain.Member, error) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
func (m *mwRepo) UpdateMember(context.Context, *domain.Member) error { return nil }
|
||||||
func (m *mwRepo) ListPage(context.Context, int, int, string, string) ([]*domain.Member, int64, error) {
|
func (m *mwRepo) ListPage(context.Context, int, int, string, string) ([]*domain.Member, int64, error) {
|
||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
func (m *mwRepo) CountActiveAdmins(context.Context) (int64, error) { return 0, nil }
|
func (m *mwRepo) ListAllMembers(context.Context) ([]*domain.Member, error) { return nil, nil }
|
||||||
|
func (m *mwRepo) CountActiveAdmins(context.Context) (int64, error) { return 0, nil }
|
||||||
func (m *mwRepo) CreateIdentity(context.Context, *domain.Identity) error {
|
func (m *mwRepo) CreateIdentity(context.Context, *domain.Identity) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -55,11 +63,11 @@ func (m *mwRepo) UpdateIdentityPassword(context.Context, string, string, string)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mwRepo) SetCode(string, string, string, time.Duration) {}
|
func (m *mwRepo) SetCode(string, string, string, time.Duration) {}
|
||||||
func (m *mwRepo) CheckCode(string, string, string) bool { return false }
|
func (m *mwRepo) CheckCode(string, string, string) bool { return false }
|
||||||
func (m *mwRepo) PeekCode(string, string, string) bool { return false }
|
func (m *mwRepo) PeekCode(string, string, string) bool { return false }
|
||||||
func (m *mwRepo) SaveRefresh(string, int64, time.Time) {}
|
func (m *mwRepo) SaveRefresh(string, int64, time.Time) {}
|
||||||
func (m *mwRepo) ConsumeRefresh(string) (int64, bool) { return 0, false }
|
func (m *mwRepo) ConsumeRefresh(string) (int64, bool) { return 0, false }
|
||||||
func (m *mwRepo) RevokeRefresh(string) {}
|
func (m *mwRepo) RevokeRefresh(string) {}
|
||||||
func (m *mwRepo) RevokeAllRefreshByUID(context.Context, int64) error {
|
func (m *mwRepo) RevokeAllRefreshByUID(context.Context, int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const (
|
||||||
TemplateComposeMimic = "compose_mimic"
|
TemplateComposeMimic = "compose_mimic"
|
||||||
// TemplatePlayGenerateScript — 互回/串場:一次產完整劇本(背景 job,避免 HTTP 卡住)
|
// TemplatePlayGenerateScript — 互回/串場:一次產完整劇本(背景 job,避免 HTTP 卡住)
|
||||||
TemplatePlayGenerateScript = "play_generate_script"
|
TemplatePlayGenerateScript = "play_generate_script"
|
||||||
|
TemplateScoutScan = "scout_scan"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Job 生命週期契約(所有模板必須遵守,worker / API 入列時):
|
// Job 生命週期契約(所有模板必須遵守,worker / API 入列時):
|
||||||
|
|
@ -53,6 +54,9 @@ type Job struct {
|
||||||
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
||||||
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
||||||
CompletedAt int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"`
|
CompletedAt int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"`
|
||||||
|
// Version is incremented by every persisted mutation and makes lifecycle
|
||||||
|
// writes compare-and-swap safe across gateway and worker instances.
|
||||||
|
Version int64 `bson:"version" json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultTokenRenewDelayNs — 連帳後約 30 天做第一次 renew(長效 token ~60 天)
|
// DefaultTokenRenewDelayNs — 連帳後約 30 天做第一次 renew(長效 token ~60 天)
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,17 @@ func (s *MemoryStore) Insert(_ context.Context, j *domain.Job) error {
|
||||||
func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error {
|
func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if _, ok := s.byID[j.ID]; !ok {
|
stored, ok := s.byID[j.ID]
|
||||||
|
if !ok {
|
||||||
return domain.ErrNotFound
|
return domain.ErrNotFound
|
||||||
}
|
}
|
||||||
|
if stored.Version != j.Version {
|
||||||
|
return domain.ErrIllegalStatus
|
||||||
|
}
|
||||||
cp := *j
|
cp := *j
|
||||||
|
cp.Version++
|
||||||
s.byID[j.ID] = &cp
|
s.byID[j.ID] = &cp
|
||||||
|
j.Version = cp.Version
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +137,7 @@ func (s *MemoryStore) ClaimNext(_ context.Context, workerID string) (*domain.Job
|
||||||
best.Status = to
|
best.Status = to
|
||||||
best.WorkerID = workerID
|
best.WorkerID = workerID
|
||||||
best.UpdatedAt = now
|
best.UpdatedAt = now
|
||||||
|
best.Version++
|
||||||
if best.ProgressSummary == "" {
|
if best.ProgressSummary == "" {
|
||||||
best.ProgressSummary = "已由 worker 領取(" + workerID + ")"
|
best.ProgressSummary = "已由 worker 領取(" + workerID + ")"
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +161,7 @@ func (s *MemoryStore) CancelPendingByRef(_ context.Context, ownerUID int64, temp
|
||||||
j.ProgressSummary = "已由新的定期排程取代"
|
j.ProgressSummary = "已由新的定期排程取代"
|
||||||
j.CompletedAt = now
|
j.CompletedAt = now
|
||||||
j.UpdatedAt = now
|
j.UpdatedAt = now
|
||||||
|
j.Version++
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,21 @@ func (s *MonStore) Insert(ctx context.Context, j *domain.Job) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) Update(ctx context.Context, j *domain.Job) error {
|
func (s *MonStore) Update(ctx context.Context, j *domain.Job) error {
|
||||||
|
if j == nil {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
expectedVersion := j.Version
|
||||||
j.UpdatedAt = domain.NowNano()
|
j.UpdatedAt = domain.NowNano()
|
||||||
res, err := s.jobs.ReplaceOne(ctx, bson.M{"_id": j.ID}, j)
|
candidate := *j
|
||||||
|
candidate.Version = expectedVersion + 1
|
||||||
|
res, err := s.jobs.ReplaceOne(ctx, bson.M{"_id": j.ID, "version": expectedVersion}, &candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if res.MatchedCount == 0 {
|
if res.MatchedCount == 0 {
|
||||||
return domain.ErrNotFound
|
return domain.ErrIllegalStatus
|
||||||
}
|
}
|
||||||
|
j.Version = candidate.Version
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +134,7 @@ func (s *MonStore) ClaimNext(ctx context.Context, workerID string) (*domain.Job,
|
||||||
"worker_id": workerID,
|
"worker_id": workerID,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
"progress_summary": "已由 worker 領取(" + workerID + ")",
|
"progress_summary": "已由 worker 領取(" + workerID + ")",
|
||||||
}}
|
}, "$inc": bson.M{"version": 1}}
|
||||||
opts := options.FindOneAndUpdate().
|
opts := options.FindOneAndUpdate().
|
||||||
SetSort(bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}).
|
SetSort(bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}).
|
||||||
SetReturnDocument(options.After)
|
SetReturnDocument(options.After)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,22 @@ func (s *Service) StartDemo(ctx context.Context, ownerUID int64) (*domain.Job, e
|
||||||
return j, nil
|
return j, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScheduleScoutScan enqueues an immutable Scout brief for the worker.
|
||||||
|
func (s *Service) ScheduleScoutScan(ctx context.Context, ownerUID int64, themeKey, payload string) (*domain.Job, error) {
|
||||||
|
if strings.TrimSpace(payload) == "" {
|
||||||
|
return nil, fmt.Errorf("scout scan payload required")
|
||||||
|
}
|
||||||
|
now := domain.NowNano()
|
||||||
|
j := &domain.Job{ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplateScoutScan,
|
||||||
|
Status: domain.StatusQueued, RefID: themeKey, Payload: payload,
|
||||||
|
ProgressSummary: "海巡已排程 · 等待 worker", CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := s.Repo.Insert(ctx, j); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.notify(ctx, j)
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
|
func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
|
||||||
return s.Repo.ListByOwner(ctx, ownerUID)
|
return s.Repo.ListByOwner(ctx, ownerUID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -575,19 +575,9 @@ func (s *AccountService) VerifyPlatformAuthResult(ctx context.Context, loginID,
|
||||||
return CheckPasswordHash(password, ident.PasswordHash), nil
|
return CheckPasswordHash(password, ident.PasswordHash), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- OAuth providers (real when configured; else ErrOAuthFailed) ----------
|
// ---------- OAuth providers ----------
|
||||||
|
|
||||||
func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) {
|
func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) {
|
||||||
// Dev/mock: accept "mock:<subject>:<email>" when no Google client configured
|
|
||||||
if strings.HasPrefix(idToken, "mock:") {
|
|
||||||
parts := strings.SplitN(idToken, ":", 3)
|
|
||||||
if len(parts) >= 3 {
|
|
||||||
return parts[1], parts[2], parts[1], nil
|
|
||||||
}
|
|
||||||
if len(parts) == 2 {
|
|
||||||
return parts[1], parts[1] + "@oauth.local", parts[1], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info, e := googleUserInfo(ctx, idToken)
|
info, e := googleUserInfo(ctx, idToken)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return "", "", "", domain.ErrOAuthFailed
|
return "", "", "", domain.ErrOAuthFailed
|
||||||
|
|
@ -596,10 +586,6 @@ func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AccountService) LineCodeToProfile(ctx context.Context, code, redirectURI string) (subject, displayName, email string, err error) {
|
func (s *AccountService) LineCodeToProfile(ctx context.Context, code, redirectURI string) (subject, displayName, email string, err error) {
|
||||||
if strings.HasPrefix(code, "mock:") {
|
|
||||||
sub := strings.TrimPrefix(code, "mock:")
|
|
||||||
return sub, "line " + sub, "", nil
|
|
||||||
}
|
|
||||||
if s.LineClientID == "" || s.LineClientSecret == "" {
|
if s.LineClientID == "" || s.LineClientSecret == "" {
|
||||||
return "", "", "", domain.ErrOAuthFailed
|
return "", "", "", domain.ErrOAuthFailed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -370,17 +370,14 @@ func TestBind_Unbind(t *testing.T) {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginOAuth_Mock(t *testing.T) {
|
func TestLoginOAuth_TrustedProviderIdentity(t *testing.T) {
|
||||||
store := newFake()
|
store := newFake()
|
||||||
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
|
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
|
||||||
sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:g123:u@g.com")
|
m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g123", "u@g.com", "G123")
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "g123", sub)
|
|
||||||
m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, pair.AccessToken)
|
require.NotEmpty(t, pair.AccessToken)
|
||||||
require.True(t, m.UID >= domain.MinMemberUID)
|
require.True(t, m.UID >= domain.MinMemberUID)
|
||||||
m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
|
m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g123", "u@g.com", "G123")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, m.UID, m2.UID)
|
require.Equal(t, m.UID, m2.UID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -319,23 +319,17 @@ func TestAP_08_AvatarURLSetAndClear(t *testing.T) {
|
||||||
require.Empty(t, out2.AvatarURL)
|
require.Empty(t, out2.AvatarURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- AO Third-party OAuth (service / mock provider) ----------
|
// ---------- AO Third-party OAuth (verified provider identity handed to service) ----------
|
||||||
|
|
||||||
func TestAO_01_OAuthProviderStartMock(t *testing.T) {
|
func TestAO_01_OAuthProviderRejectsUnverifiedToken(t *testing.T) {
|
||||||
// Provider mock path via VerifyGoogleIDToken mock: prefix (API-level start is logic stub)
|
|
||||||
_, svc := newM1Svc()
|
_, svc := newM1Svc()
|
||||||
sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao01:ao01@g.com")
|
_, _, _, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao01:ao01@g.com")
|
||||||
require.NoError(t, err)
|
require.ErrorIs(t, err, domain.ErrOAuthFailed)
|
||||||
require.Equal(t, "ao01", sub)
|
|
||||||
require.Equal(t, "ao01@g.com", email)
|
|
||||||
require.NotEmpty(t, name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAO_02_OAuthFirstLoginCreatesMember(t *testing.T) {
|
func TestAO_02_OAuthFirstLoginCreatesMember(t *testing.T) {
|
||||||
_, svc := newM1Svc()
|
_, svc := newM1Svc()
|
||||||
sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao02sub:ao02@g.com")
|
m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "ao02sub", "ao02@g.com", "AO2")
|
||||||
require.NoError(t, err)
|
|
||||||
m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, m.UID >= domain.MinMemberUID)
|
require.True(t, m.UID >= domain.MinMemberUID)
|
||||||
require.NotEmpty(t, pair.AccessToken)
|
require.NotEmpty(t, pair.AccessToken)
|
||||||
|
|
@ -343,11 +337,9 @@ func TestAO_02_OAuthFirstLoginCreatesMember(t *testing.T) {
|
||||||
|
|
||||||
func TestAO_03_OAuthSecondLoginSameUID(t *testing.T) {
|
func TestAO_03_OAuthSecondLoginSameUID(t *testing.T) {
|
||||||
_, svc := newM1Svc()
|
_, svc := newM1Svc()
|
||||||
sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao03sub:ao03@g.com")
|
m1, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "ao03sub", "ao03@g.com", "AO3")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
m1, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
|
m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "ao03sub", "ao03@g.com", "AO3")
|
||||||
require.NoError(t, err)
|
|
||||||
m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, m1.UID, m2.UID)
|
require.Equal(t, m1.UID, m2.UID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("scout not found")
|
ErrNotFound = errors.New("scout not found")
|
||||||
ErrForbidden = errors.New("scout forbidden")
|
ErrForbidden = errors.New("scout forbidden")
|
||||||
ErrValidation = errors.New("scout validation")
|
ErrValidation = errors.New("scout validation")
|
||||||
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
|
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
|
||||||
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
|
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
|
||||||
ErrHasProducts = errors.New("brand has products; remove products first")
|
ErrHasProducts = errors.New("brand has products; remove products first")
|
||||||
)
|
)
|
||||||
|
|
||||||
func NowNano() int64 { return time.Now().UTC().UnixNano() }
|
func NowNano() int64 { return time.Now().UTC().UnixNano() }
|
||||||
|
|
@ -24,10 +24,19 @@ const (
|
||||||
OutreachDrafted = "drafted"
|
OutreachDrafted = "drafted"
|
||||||
OutreachPublished = "published"
|
OutreachPublished = "published"
|
||||||
OutreachSkipped = "skipped"
|
OutreachSkipped = "skipped"
|
||||||
|
OutreachQueued = "queued"
|
||||||
|
|
||||||
ModeProduct = "product"
|
ModeProduct = "product"
|
||||||
ModeTheme = "theme"
|
ModeTheme = "theme"
|
||||||
ModeActivity = "activity"
|
ModeActivity = "activity"
|
||||||
|
|
||||||
|
ClassificationSeekingHelp = "seeking_help"
|
||||||
|
ClassificationSeekingRecommendation = "seeking_recommendation"
|
||||||
|
ClassificationProviderOffer = "provider_offer"
|
||||||
|
ClassificationDiscussion = "discussion"
|
||||||
|
ClassificationAsking = "asking"
|
||||||
|
ClassificationAnnouncement = "announcement"
|
||||||
|
ClassificationNoise = "noise"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Brand struct {
|
type Brand struct {
|
||||||
|
|
@ -61,24 +70,26 @@ type ActiveBrand struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunBrief struct {
|
type RunBrief struct {
|
||||||
Intent string `json:"intent"`
|
Intent string `json:"intent"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
BrandID string `json:"brand_id,omitempty"`
|
BrandID string `json:"brand_id,omitempty"`
|
||||||
ProductID string `json:"product_id,omitempty"`
|
ProductID string `json:"product_id,omitempty"`
|
||||||
ProductLabel string `json:"product_label,omitempty"`
|
ProductLabel string `json:"product_label,omitempty"`
|
||||||
Pains []string `json:"pains"`
|
Pains []string `json:"pains"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Periphery []string `json:"periphery"`
|
Periphery []string `json:"periphery"`
|
||||||
ScanTerms []string `json:"scan_terms"`
|
ScanTerms []string `json:"scan_terms"`
|
||||||
PlacementNote string `json:"placement_note,omitempty"`
|
PlacementNote string `json:"placement_note,omitempty"`
|
||||||
ResponseStance string `json:"response_stance,omitempty"`
|
ResponseStance string `json:"response_stance,omitempty"`
|
||||||
ThemeKey string `json:"theme_key,omitempty"`
|
ThemeKey string `json:"theme_key,omitempty"`
|
||||||
ThemeLabel string `json:"theme_label,omitempty"`
|
ThemeLabel string `json:"theme_label,omitempty"`
|
||||||
ProductContext string `json:"product_context,omitempty"`
|
ProductContext string `json:"product_context,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID string `bson:"_id" json:"id"`
|
ID string `bson:"_id" json:"id"`
|
||||||
|
ExternalID string `bson:"external_id,omitempty" json:"external_id,omitempty"`
|
||||||
|
Permalink string `bson:"permalink,omitempty" json:"permalink,omitempty"`
|
||||||
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
|
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
|
||||||
BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"`
|
BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"`
|
||||||
Author string `bson:"author" json:"author"`
|
Author string `bson:"author" json:"author"`
|
||||||
|
|
@ -87,7 +98,9 @@ type Post struct {
|
||||||
Opportunity string `bson:"opportunity" json:"opportunity"`
|
Opportunity string `bson:"opportunity" json:"opportunity"`
|
||||||
OutreachStatus string `bson:"outreach_status" json:"outreach_status"`
|
OutreachStatus string `bson:"outreach_status" json:"outreach_status"`
|
||||||
DraftText string `bson:"draft_text,omitempty" json:"draft_text,omitempty"`
|
DraftText string `bson:"draft_text,omitempty" json:"draft_text,omitempty"`
|
||||||
|
OutboxID string `bson:"outbox_id,omitempty" json:"outbox_id,omitempty"`
|
||||||
Score int `bson:"score" json:"score"`
|
Score int `bson:"score" json:"score"`
|
||||||
|
Classification string `bson:"classification,omitempty" json:"classification,omitempty"`
|
||||||
MatchedProductID string `bson:"matched_product_id,omitempty" json:"matched_product_id,omitempty"`
|
MatchedProductID string `bson:"matched_product_id,omitempty" json:"matched_product_id,omitempty"`
|
||||||
MatchedProductLabel string `bson:"matched_product_label,omitempty" json:"matched_product_label,omitempty"`
|
MatchedProductLabel string `bson:"matched_product_label,omitempty" json:"matched_product_label,omitempty"`
|
||||||
MatchReason string `bson:"match_reason,omitempty" json:"match_reason,omitempty"`
|
MatchReason string `bson:"match_reason,omitempty" json:"match_reason,omitempty"`
|
||||||
|
|
@ -109,9 +122,10 @@ type Homework struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CrawlerSession struct {
|
type CrawlerSession struct {
|
||||||
OwnerUID int64 `bson:"_id" json:"owner_uid"`
|
OwnerUID int64 `bson:"_id" json:"owner_uid"`
|
||||||
Token string `bson:"token" json:"token"`
|
StorageStateEnc string `bson:"storage_state_enc" json:"storage_state_enc"`
|
||||||
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
||||||
|
ExpiresAt int64 `bson:"expires_at" json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImportDraft struct {
|
type ImportDraft struct {
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ func (s *MemoryStore) GetCrawlerSession(_ context.Context, ownerUID int64) (*dom
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
c, ok := s.crawler[ownerUID]
|
c, ok := s.crawler[ownerUID]
|
||||||
if !ok || c.Token == "" {
|
if !ok || c.StorageStateEnc == "" {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
cp := *c
|
cp := *c
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,12 @@ type MonStore struct {
|
||||||
func NewMonStore(uri, database string) *MonStore {
|
func NewMonStore(uri, database string) *MonStore {
|
||||||
uri = libmongo.MustMongoURI(uri)
|
uri = libmongo.MustMongoURI(uri)
|
||||||
return &MonStore{
|
return &MonStore{
|
||||||
brands: mon.MustNewModel(uri, database, "scout_brands"),
|
brands: mon.MustNewModel(uri, database, "scout_brands"),
|
||||||
products: mon.MustNewModel(uri, database, "scout_products"),
|
products: mon.MustNewModel(uri, database, "scout_products"),
|
||||||
active: mon.MustNewModel(uri, database, "scout_active_brand"),
|
active: mon.MustNewModel(uri, database, "scout_active_brand"),
|
||||||
posts: mon.MustNewModel(uri, database, "scout_posts"),
|
posts: mon.MustNewModel(uri, database, "scout_posts"),
|
||||||
hw: mon.MustNewModel(uri, database, "scout_homework"),
|
hw: mon.MustNewModel(uri, database, "scout_homework"),
|
||||||
crawler: mon.MustNewModel(uri, database, "scout_crawler_session"),
|
crawler: mon.MustNewModel(uri, database, "scout_crawler_session"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,7 +166,7 @@ func (s *MonStore) SaveHomework(ctx context.Context, h *domain.Homework) error {
|
||||||
}
|
}
|
||||||
func (s *MonStore) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) {
|
func (s *MonStore) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) {
|
||||||
type hwDoc struct {
|
type hwDoc struct {
|
||||||
ID string `bson:"_id"`
|
ID string `bson:"_id"`
|
||||||
domain.Homework `bson:",inline"`
|
domain.Homework `bson:",inline"`
|
||||||
}
|
}
|
||||||
var doc hwDoc
|
var doc hwDoc
|
||||||
|
|
@ -205,7 +205,7 @@ func (s *MonStore) GetCrawlerSession(ctx context.Context, ownerUID int64) (*doma
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if c.Token == "" {
|
if c.StorageStateEnc == "" {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
return &c, nil
|
return &c, nil
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChromeCrawlerProvider is the private worker-to-browser boundary. The
|
||||||
|
// browser service receives decrypted state only for this request.
|
||||||
|
type ChromeCrawlerProvider interface {
|
||||||
|
SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]ThreadSearchResult, error)
|
||||||
|
ResolveMediaID(ctx context.Context, storageState, permalink string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPCrawlerProvider) ResolveMediaID(ctx context.Context, storageState, permalink string) (string, error) {
|
||||||
|
if p == nil || p.Endpoint == "" || p.Token == "" {
|
||||||
|
return "", fmt.Errorf("Chrome crawler is not configured")
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]string{"storage_state": storageState, "permalink": permalink})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Endpoint+"/v1/threads/resolve", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.Token)
|
||||||
|
client := p.HTTP
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 95 * time.Second}
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, truncate(string(raw), 160))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !isNumericMediaID(out.MediaID) {
|
||||||
|
return "", fmt.Errorf("Chrome resolver returned no numeric media ID")
|
||||||
|
}
|
||||||
|
return out.MediaID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPCrawlerProvider struct {
|
||||||
|
Endpoint string
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPCrawlerProvider(endpoint, token string) *HTTPCrawlerProvider {
|
||||||
|
return &HTTPCrawlerProvider{Endpoint: strings.TrimRight(strings.TrimSpace(endpoint), "/"), Token: strings.TrimSpace(token)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]ThreadSearchResult, error) {
|
||||||
|
if p == nil || p.Endpoint == "" || p.Token == "" {
|
||||||
|
return nil, fmt.Errorf("Chrome crawler is not configured")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(storageState) == "" {
|
||||||
|
return nil, fmt.Errorf("crawler session required")
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 30 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]any{"storage_state": storageState, "terms": nonEmptyTerms(terms), "limit": limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(runCtx, http.MethodPost, p.Endpoint+"/v1/threads/search", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.Token)
|
||||||
|
client := p.HTTP
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 95 * time.Second}
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Chrome crawler request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, truncate(string(raw), 160))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Posts []struct {
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"posts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("Chrome crawler response invalid: %w", err)
|
||||||
|
}
|
||||||
|
results := make([]ThreadSearchResult, 0, len(out.Posts))
|
||||||
|
for _, post := range out.Posts {
|
||||||
|
if !isThreadsURL(post.Permalink) || strings.TrimSpace(post.Text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, ThreadSearchResult{URL: post.Permalink, Title: post.Author, Snippet: post.Text})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const exaSearchURL = "https://api.exa.ai/search"
|
||||||
|
|
||||||
|
// ThreadSearchProvider finds public Threads posts for a Scout scan.
|
||||||
|
type ThreadSearchProvider interface {
|
||||||
|
SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadSearchResult struct {
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Snippet string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExaThreadsProvider searches only Threads-owned domains through Exa.
|
||||||
|
type ExaThreadsProvider struct {
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExaThreadsProvider(apiKey string) *ExaThreadsProvider {
|
||||||
|
return &ExaThreadsProvider{
|
||||||
|
APIKey: strings.TrimSpace(apiKey),
|
||||||
|
BaseURL: exaSearchURL,
|
||||||
|
HTTP: &http.Client{Timeout: 25 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDefaultExaThreadsProvider() *ExaThreadsProvider {
|
||||||
|
key := os.Getenv("EXA_API_KEY")
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
key = os.Getenv("EXA_KEY")
|
||||||
|
}
|
||||||
|
return NewExaThreadsProvider(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
|
||||||
|
if p == nil || strings.TrimSpace(p.APIKey) == "" {
|
||||||
|
return nil, fmt.Errorf("exa Threads search is not configured")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
if limit > 20 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
query := strings.Join(nonEmptyTerms(terms), " ")
|
||||||
|
if query == "" {
|
||||||
|
return nil, fmt.Errorf("exa Threads search query required")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"query": query,
|
||||||
|
"type": "auto",
|
||||||
|
"numResults": limit,
|
||||||
|
"includeDomains": []string{"threads.net", "threads.com"},
|
||||||
|
"contents": map[string]any{
|
||||||
|
"highlights": true,
|
||||||
|
"text": map[string]any{"maxCharacters": 400},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
baseURL := p.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = exaSearchURL
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-api-key", p.APIKey)
|
||||||
|
httpClient := p.HTTP
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: 25 * time.Second}
|
||||||
|
}
|
||||||
|
res, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("exa status %d: %s", res.StatusCode, truncate(string(raw), 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Highlights []string `json:"highlights"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]ThreadSearchResult, 0, len(response.Results))
|
||||||
|
for _, hit := range response.Results {
|
||||||
|
url := strings.TrimSpace(hit.URL)
|
||||||
|
if !isThreadsURL(url) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snippet := firstNonEmpty(hit.Highlights)
|
||||||
|
if snippet == "" {
|
||||||
|
snippet = strings.TrimSpace(hit.Text)
|
||||||
|
}
|
||||||
|
if snippet == "" {
|
||||||
|
snippet = strings.TrimSpace(hit.Title)
|
||||||
|
}
|
||||||
|
results = append(results, ThreadSearchResult{URL: url, Title: strings.TrimSpace(hit.Title), Snippet: truncate(snippet, 400)})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isThreadsURL(raw string) bool {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := strings.ToLower(u.Hostname())
|
||||||
|
return host == "threads.net" || strings.HasSuffix(host, ".threads.net") || host == "threads.com" || strings.HasSuffix(host, ".threads.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmptyTerms(terms []string) []string {
|
||||||
|
out := make([]string, 0, len(terms))
|
||||||
|
for _, term := range terms {
|
||||||
|
if term = strings.TrimSpace(term); term != "" {
|
||||||
|
out = append(out, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values []string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value = strings.TrimSpace(value); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExaThreadsProviderSearchesThreadsDomains(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPost, r.Method)
|
||||||
|
require.Equal(t, "test-key", r.Header.Get("x-api-key"))
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||||
|
require.Equal(t, []any{"threads.net", "threads.com"}, body["includeDomains"])
|
||||||
|
_, _ = w.Write([]byte(`{"results":[{"title":"Not a Threads post","url":"https://example.com/post/1","highlights":["Ignore me"]},{"title":"Example","url":"https://www.threads.net/@alice/post/1","highlights":["A matching post"]}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewExaThreadsProvider("test-key")
|
||||||
|
provider.BaseURL = server.URL
|
||||||
|
results, err := provider.SearchThreads(context.Background(), []string{"sensitive skin"}, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []ThreadSearchResult{{
|
||||||
|
URL: "https://www.threads.net/@alice/post/1", Title: "Example", Snippet: "A matching post",
|
||||||
|
}}, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExaThreadsProviderRejectsMissingKey(t *testing.T) {
|
||||||
|
_, err := NewExaThreadsProvider("").SearchThreads(context.Background(), []string{"query"}, 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ package usecase_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"apps/backend/internal/module/scout/domain"
|
"apps/backend/internal/module/scout/domain"
|
||||||
|
|
@ -16,6 +18,48 @@ type staticDevMode struct {
|
||||||
on bool
|
on bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeThreadSearchProvider struct{}
|
||||||
|
|
||||||
|
type fakeChromeCrawler struct{ fakeThreadSearchProvider }
|
||||||
|
|
||||||
|
type failingChromeCrawler struct{ fakeChromeCrawler }
|
||||||
|
|
||||||
|
type fakeReplyQueue struct{}
|
||||||
|
|
||||||
|
func (fakeThreadSearchProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]usecase.ThreadSearchResult, error) {
|
||||||
|
results := make([]usecase.ThreadSearchResult, 0, limit)
|
||||||
|
for i, term := range terms {
|
||||||
|
if i == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
results = append(results, usecase.ThreadSearchResult{
|
||||||
|
URL: "https://www.threads.net/@test_user/post/" + term,
|
||||||
|
Title: term,
|
||||||
|
Snippet: "realistic result for " + term,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeChromeCrawler) SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]usecase.ThreadSearchResult, error) {
|
||||||
|
if strings.TrimSpace(storageState) == "" {
|
||||||
|
return nil, domain.ErrNoCrawlerSession
|
||||||
|
}
|
||||||
|
return f.SearchThreads(ctx, terms, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeChromeCrawler) ResolveMediaID(_ context.Context, _ string, _ string) (string, error) {
|
||||||
|
return "123456789012345", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failingChromeCrawler) ResolveMediaID(_ context.Context, _ string, _ string) (string, error) {
|
||||||
|
return "", fmt.Errorf("crawler session expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeReplyQueue) QueueExternalReply(_ context.Context, _ int64, _, _, _, _ string) (string, error) {
|
||||||
|
return "outbox_test", nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *staticDevMode) DevModeEnabled(_ context.Context, _ int64) (bool, error) {
|
func (s *staticDevMode) DevModeEnabled(_ context.Context, _ int64) (bool, error) {
|
||||||
return s.on, nil
|
return s.on, nil
|
||||||
}
|
}
|
||||||
|
|
@ -24,8 +68,11 @@ func newScout() (*usecase.Service, *studioPublish.FakeTransport, *staticDevMode)
|
||||||
tp := studioPublish.NewFake()
|
tp := studioPublish.NewFake()
|
||||||
dev := &staticDevMode{}
|
dev := &staticDevMode{}
|
||||||
svc := usecase.New(repository.NewMemory())
|
svc := usecase.New(repository.NewMemory())
|
||||||
|
svc.SessionSecret = "test-crawler-session-secret"
|
||||||
svc.Transport = tp
|
svc.Transport = tp
|
||||||
svc.Settings = dev
|
svc.Settings = dev
|
||||||
|
svc.Provider = fakeThreadSearchProvider{}
|
||||||
|
svc.Crawler = fakeChromeCrawler{}
|
||||||
return svc, tp, dev
|
return svc, tp, dev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +130,7 @@ func TestSC_06_CrawlerPath(t *testing.T) {
|
||||||
svc, _, dev := newScout()
|
svc, _, dev := newScout()
|
||||||
uid := int64(5_002_006)
|
uid := int64(5_002_006)
|
||||||
dev.on = true
|
dev.on = true
|
||||||
require.NoError(t, svc.SetCrawlerSession(context.Background(), uid, "ext-session-1"))
|
require.NoError(t, svc.SetCrawlerSession(context.Background(), uid, validStorageState()))
|
||||||
brief, _ := svc.PrepareBrief(context.Background(), uid, "成分黨", "", "", "value", false)
|
brief, _ := svc.PrepareBrief(context.Background(), uid, "成分黨", "", "", "value", false)
|
||||||
posts, err := svc.RunScanFromBrief(context.Background(), uid, brief)
|
posts, err := svc.RunScanFromBrief(context.Background(), uid, brief)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -99,6 +146,44 @@ func TestSC_07_NoSession(t *testing.T) {
|
||||||
require.ErrorIs(t, err, domain.ErrNoCrawlerSession)
|
require.ErrorIs(t, err, domain.ErrNoCrawlerSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrawlerSessionEncryptedAndValidated(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := repository.NewMemory()
|
||||||
|
svc := usecase.New(store)
|
||||||
|
svc.SessionSecret = "test-crawler-session-secret"
|
||||||
|
state := validStorageState()
|
||||||
|
|
||||||
|
require.NoError(t, svc.SetCrawlerSession(ctx, 1, state))
|
||||||
|
persisted, err := store.GetCrawlerSession(ctx, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, state, persisted.StorageStateEnc)
|
||||||
|
require.Positive(t, persisted.UpdatedAt)
|
||||||
|
require.Positive(t, persisted.ExpiresAt)
|
||||||
|
got, err := svc.GetCrawlerSessionToken(ctx, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, state, got)
|
||||||
|
|
||||||
|
for _, invalid := range []string{
|
||||||
|
"not json",
|
||||||
|
"[]",
|
||||||
|
`{"cookies":[]}`,
|
||||||
|
`{"cookies":[{"domain":"evil.example","expires":4102444800}]}`,
|
||||||
|
`{"cookies":null}`,
|
||||||
|
strings.Repeat("x", 256*1024+1),
|
||||||
|
} {
|
||||||
|
require.ErrorIs(t, svc.SetCrawlerSession(ctx, 1, invalid), domain.ErrValidation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrawlerSessionRequiresSecret(t *testing.T) {
|
||||||
|
svc := usecase.New(repository.NewMemory())
|
||||||
|
require.ErrorIs(t, svc.SetCrawlerSession(context.Background(), 1, validStorageState()), domain.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validStorageState() string {
|
||||||
|
return `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`
|
||||||
|
}
|
||||||
|
|
||||||
func TestSC_08_ListPostsAfterScan(t *testing.T) {
|
func TestSC_08_ListPostsAfterScan(t *testing.T) {
|
||||||
svc, _, _ := newScout()
|
svc, _, _ := newScout()
|
||||||
uid := int64(5_002_008)
|
uid := int64(5_002_008)
|
||||||
|
|
@ -129,34 +214,31 @@ func TestSC_10_SkipAndMark(t *testing.T) {
|
||||||
p, err := svc.SkipOutreach(context.Background(), uid, posts[0].ID)
|
p, err := svc.SkipOutreach(context.Background(), uid, posts[0].ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, domain.OutreachSkipped, p.OutreachStatus)
|
require.Equal(t, domain.OutreachSkipped, p.OutreachStatus)
|
||||||
if len(posts) > 1 {
|
_, err = svc.MarkPublished(context.Background(), uid, posts[0].ID)
|
||||||
p2, err := svc.MarkPublished(context.Background(), uid, posts[1].ID)
|
require.Error(t, err)
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, domain.OutreachPublished, p2.OutreachStatus)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSC_11_SendOutreach(t *testing.T) {
|
func TestSC_11_SendOutreach(t *testing.T) {
|
||||||
svc, tp, _ := newScout()
|
svc, _, _ := newScout()
|
||||||
uid := int64(5_002_011)
|
uid := int64(5_002_011)
|
||||||
brief, _ := svc.PrepareBrief(context.Background(), uid, "send", "", "", "value", false)
|
brief, _ := svc.PrepareBrief(context.Background(), uid, "send", "", "", "value", false)
|
||||||
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
|
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
|
||||||
p, err := svc.SendOutreach(context.Background(), uid, posts[0].ID, "你好呀", "acc1")
|
_, err := svc.SendOutreach(context.Background(), uid, posts[0].ID, "你好呀", "acc1")
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, domain.OutreachPublished, p.OutreachStatus)
|
|
||||||
require.Equal(t, 1, tp.CallCount())
|
|
||||||
// fail path
|
|
||||||
tp.Fail = true
|
|
||||||
brief2, _ := svc.PrepareBrief(context.Background(), uid, "sendfail", "", "", "value", false)
|
|
||||||
posts2, _ := svc.RunScanFromBrief(context.Background(), uid, brief2)
|
|
||||||
_, err = svc.SendOutreach(context.Background(), uid, posts2[0].ID, "nope", "")
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
got, _ := svc.ListPosts(context.Background(), uid, "")
|
}
|
||||||
for _, x := range got {
|
|
||||||
if x.ID == posts2[0].ID {
|
func TestSC_11_SendOutreachResolverFailureIsValidation(t *testing.T) {
|
||||||
require.NotEqual(t, domain.OutreachPublished, x.OutreachStatus)
|
svc, _, _ := newScout()
|
||||||
}
|
uid := int64(5_002_012)
|
||||||
}
|
svc.Crawler = failingChromeCrawler{}
|
||||||
|
svc.ReplyQueue = fakeReplyQueue{}
|
||||||
|
require.NoError(t, svc.SetCrawlerSession(context.Background(), uid, validStorageState()))
|
||||||
|
brief, _ := svc.PrepareBrief(context.Background(), uid, "send", "", "", "value", false)
|
||||||
|
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
|
||||||
|
|
||||||
|
_, err := svc.SendOutreach(context.Background(), uid, posts[0].ID, "你好呀", "acc1")
|
||||||
|
require.ErrorIs(t, err, domain.ErrValidation)
|
||||||
|
require.ErrorContains(t, err, "unable to resolve the target Threads post")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSC_12_RemovePostTheme(t *testing.T) {
|
func TestSC_12_RemovePostTheme(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/scout/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type classifiedPost struct {
|
||||||
|
classification string
|
||||||
|
score int
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func planScanTerms(brief *domain.RunBrief) []string {
|
||||||
|
return dedupeTerms(brief.Pains, brief.Tags, brief.Periphery, []string{brief.Intent})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeTerms(groups ...[]string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []string
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, term := range group {
|
||||||
|
term = strings.Join(strings.Fields(strings.TrimSpace(term)), " ")
|
||||||
|
key := strings.ToLower(term)
|
||||||
|
if term == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyPost(mode, text string, terms []string) classifiedPost {
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
signals := matchedSignals(lower, terms)
|
||||||
|
if hasAny(lower, "giveaway", "抽獎", "follow for follow", "互追", "crypto", "賺錢") {
|
||||||
|
return classifiedPost{domain.ClassificationNoise, 0, "noise signal"}
|
||||||
|
}
|
||||||
|
if mode == domain.ModeActivity {
|
||||||
|
return classifyActivity(lower, signals)
|
||||||
|
}
|
||||||
|
if hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
|
||||||
|
return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal")
|
||||||
|
}
|
||||||
|
if hasAny(lower, "推薦", "求推", "有沒有推薦", "any recommendation", "what do you recommend") {
|
||||||
|
return scored(domain.ClassificationSeekingRecommendation, 60, signals, "recommendation signal")
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "?") || strings.Contains(lower, "?") || hasAny(lower, "怎麼", "如何", "請問", "求助", "help") {
|
||||||
|
return scored(domain.ClassificationSeekingHelp, 55, signals, "help-seeking signal")
|
||||||
|
}
|
||||||
|
return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyActivity(text string, signals []string) classifiedPost {
|
||||||
|
if hasAny(text, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
|
||||||
|
return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal; recency unavailable (neutral)")
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "?") || strings.Contains(text, "?") || hasAny(text, "請問", "怎麼", "如何", "有人知道", "求") {
|
||||||
|
return scored(domain.ClassificationAsking, 60, signals, "asking signal; recency unavailable (neutral)")
|
||||||
|
}
|
||||||
|
if hasAny(text, "活動", "event", "開幕", "launch", "發布", "報名", "登記", "公告") {
|
||||||
|
return scored(domain.ClassificationAnnouncement, 50, signals, "announcement signal; recency unavailable (neutral)")
|
||||||
|
}
|
||||||
|
return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal; recency unavailable (neutral)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scored(classification string, base int, signals []string, label string) classifiedPost {
|
||||||
|
score := base + len(signals)*15
|
||||||
|
if score > 100 {
|
||||||
|
score = 100
|
||||||
|
}
|
||||||
|
reason := label
|
||||||
|
if len(signals) > 0 {
|
||||||
|
reason += "; matched: " + strings.Join(signals, ", ")
|
||||||
|
}
|
||||||
|
return classifiedPost{classification, score, reason}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchedSignals(text string, terms []string) []string {
|
||||||
|
var signals []string
|
||||||
|
for _, term := range dedupeTerms(terms) {
|
||||||
|
if strings.Contains(text, strings.ToLower(term)) {
|
||||||
|
signals = append(signals, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return signals
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAny(text string, signals ...string) bool {
|
||||||
|
for _, signal := range signals {
|
||||||
|
if strings.Contains(text, signal) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalPermalink(raw string) string {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u.Scheme = "https"
|
||||||
|
u.Host = strings.ToLower(u.Host)
|
||||||
|
u.RawQuery = ""
|
||||||
|
u.Fragment = ""
|
||||||
|
u.Path = strings.TrimRight(u.Path, "/")
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func permalinkID(ownerUID int64, permalink string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.TrimSpace(permalink) + "|" + formatOwnerUID(ownerUID)))
|
||||||
|
return "sp_" + hex.EncodeToString(sum[:])[:20]
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOwnerUID(ownerUID int64) string {
|
||||||
|
return strconv.FormatInt(ownerUID, 10)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
package usecase_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/scout/domain"
|
||||||
|
"apps/backend/internal/module/scout/repository"
|
||||||
|
"apps/backend/internal/module/scout/usecase"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type capturedSearchProvider struct {
|
||||||
|
terms []string
|
||||||
|
hits []usecase.ThreadSearchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) {
|
||||||
|
svc := usecase.New(repository.NewMemory())
|
||||||
|
brand, err := svc.CreateBrand(context.Background(), 1, "B", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
product, err := svc.SaveProduct(context.Background(), 1, &domain.Product{
|
||||||
|
BrandID: brand.ID, Label: "P", PainPoints: []string{"敏感肌", "敏感肌"}, MatchTags: []string{"保養", "敏感肌"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
brief, err := svc.PrepareBrief(context.Background(), 1, "敏感肌保養", brand.ID, product.ID, "value", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"敏感肌", "保養", "使用情境", "替代方案", "成分/規格", "敏感肌保養"}, brief.ScanTerms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *capturedSearchProvider) SearchThreads(_ context.Context, terms []string, _ int) ([]usecase.ThreadSearchResult, error) {
|
||||||
|
p.terms = append([]string(nil), terms...)
|
||||||
|
return p.hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
|
||||||
|
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
|
||||||
|
{URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?"},
|
||||||
|
{URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?"},
|
||||||
|
{URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠"},
|
||||||
|
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得"},
|
||||||
|
{URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮"},
|
||||||
|
}}
|
||||||
|
store := repository.NewMemory()
|
||||||
|
svc := usecase.New(store)
|
||||||
|
svc.Provider = provider
|
||||||
|
brief := &domain.RunBrief{
|
||||||
|
Mode: domain.ModeProduct, Intent: "敏感肌保養", Pains: []string{"敏感肌", "敏感肌"},
|
||||||
|
Tags: []string{"保養", "敏感肌"}, Periphery: []string{"使用情境", "保養"},
|
||||||
|
}
|
||||||
|
brief.ScanTerms = []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}
|
||||||
|
|
||||||
|
posts, err := svc.RunScanFromBrief(context.Background(), 42, brief)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.terms)
|
||||||
|
require.Len(t, posts, 4)
|
||||||
|
byClass := map[string]*domain.Post{}
|
||||||
|
for _, post := range posts {
|
||||||
|
byClass[post.Classification] = post
|
||||||
|
require.NotEmpty(t, post.Permalink)
|
||||||
|
require.Equal(t, post.Permalink, post.ExternalID)
|
||||||
|
require.InDelta(t, 0, post.Score, 100)
|
||||||
|
require.NotEmpty(t, post.MatchReason)
|
||||||
|
}
|
||||||
|
require.Contains(t, byClass, domain.ClassificationSeekingHelp)
|
||||||
|
require.Contains(t, byClass, domain.ClassificationSeekingRecommendation)
|
||||||
|
require.Contains(t, byClass, domain.ClassificationProviderOffer)
|
||||||
|
require.Contains(t, byClass, domain.ClassificationDiscussion)
|
||||||
|
require.Contains(t, byClass[domain.ClassificationSeekingHelp].MatchReason, "敏感肌")
|
||||||
|
require.Equal(t, "https://www.threads.net/@a/post/1", byClass[domain.ClassificationSeekingHelp].Permalink)
|
||||||
|
|
||||||
|
again, err := svc.RunScanFromBrief(context.Background(), 42, brief)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, byClass[domain.ClassificationSeekingHelp].ID, again[0].ID)
|
||||||
|
persisted, err := svc.ListPosts(context.Background(), 42, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, persisted, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) {
|
||||||
|
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
|
||||||
|
{URL: "https://www.threads.net/@a/post/1", Snippet: "請問這週市集怎麼報名?"},
|
||||||
|
{URL: "https://www.threads.net/@b/post/2", Snippet: "市集活動公告,週六開幕"},
|
||||||
|
{URL: "https://www.threads.net/@c/post/3", Snippet: "市集攤位服務洽詢"},
|
||||||
|
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享市集逛街心得"},
|
||||||
|
{URL: "https://www.threads.net/@e/post/5", Snippet: "市集 crypto 賺錢群組"},
|
||||||
|
}}
|
||||||
|
svc := usecase.New(repository.NewMemory())
|
||||||
|
svc.Provider = provider
|
||||||
|
brief := &domain.RunBrief{Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}}
|
||||||
|
|
||||||
|
posts, err := svc.RunScanFromBrief(context.Background(), 7, brief)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, posts, 4)
|
||||||
|
classes := map[string]bool{}
|
||||||
|
for _, post := range posts {
|
||||||
|
classes[post.Classification] = true
|
||||||
|
require.Contains(t, post.MatchReason, "recency unavailable (neutral)")
|
||||||
|
require.Greater(t, post.Score, 0)
|
||||||
|
}
|
||||||
|
require.True(t, classes[domain.ClassificationAsking])
|
||||||
|
require.True(t, classes[domain.ClassificationAnnouncement])
|
||||||
|
require.True(t, classes[domain.ClassificationProviderOffer])
|
||||||
|
require.True(t, classes[domain.ClassificationDiscussion])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftOutreachIsDeterministicAndDoesNotNeedAI(t *testing.T) {
|
||||||
|
svc := usecase.New(repository.NewMemory())
|
||||||
|
svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{
|
||||||
|
URL: "https://www.threads.net/@author/post/1", Snippet: "請問怎麼處理頭皮困擾?",
|
||||||
|
}}}
|
||||||
|
posts, err := svc.RunScanFromBrief(context.Background(), 9, &domain.RunBrief{
|
||||||
|
Mode: domain.ModeTheme, Intent: "頭皮", ScanTerms: []string{"頭皮"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
draft, err := svc.DraftOutreach(context.Background(), 9, posts[0].ID, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "嗨 @author,看到你提到「頭皮」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。", draft.DraftText)
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,14 @@ package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"apps/backend/internal/module/ai"
|
"apps/backend/internal/module/ai"
|
||||||
"apps/backend/internal/module/scout/domain"
|
"apps/backend/internal/module/scout/domain"
|
||||||
studioDomain "apps/backend/internal/module/studio/domain"
|
|
||||||
studioPublish "apps/backend/internal/module/studio/publish"
|
studioPublish "apps/backend/internal/module/studio/publish"
|
||||||
threadsDomain "apps/backend/internal/module/threads/domain"
|
threadsDomain "apps/backend/internal/module/threads/domain"
|
||||||
|
|
||||||
|
|
@ -20,22 +21,24 @@ type SettingsReader interface {
|
||||||
DevModeEnabled(ctx context.Context, uid int64) (bool, error)
|
DevModeEnabled(ctx context.Context, uid int64) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountLookup for send outreach
|
type ReplyQueue interface {
|
||||||
type AccountLookup interface {
|
QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error)
|
||||||
Get(ctx context.Context, id string) (*threadsDomain.Account, error)
|
|
||||||
AccessToken(ctx context.Context, a *threadsDomain.Account) (string, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Repo domain.Repository
|
Repo domain.Repository
|
||||||
Settings SettingsReader
|
Settings SettingsReader
|
||||||
Transport studioPublish.Transport
|
// Transport is retained only for test construction compatibility. Scout never publishes directly.
|
||||||
Accounts AccountLookup
|
Transport studioPublish.Transport
|
||||||
AI ai.Client
|
AI ai.Client // Retained for service wiring; Scout drafts never call AI.
|
||||||
|
ReplyQueue ReplyQueue
|
||||||
|
Provider ThreadSearchProvider
|
||||||
|
Crawler ChromeCrawlerProvider
|
||||||
|
SessionSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repo domain.Repository) *Service {
|
func New(repo domain.Repository) *Service {
|
||||||
return &Service{Repo: repo}
|
return &Service{Repo: repo, Provider: newDefaultExaThreadsProvider()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) {
|
func (s *Service) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) {
|
||||||
|
|
@ -202,7 +205,7 @@ func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.I
|
||||||
label = host + " 商品"
|
label = host + " 商品"
|
||||||
}
|
}
|
||||||
return &domain.ImportDraft{
|
return &domain.ImportDraft{
|
||||||
Label: label,
|
Label: label,
|
||||||
ProductContext: "從 " + raw + " 推估的產品情境(live 可接真爬頁)",
|
ProductContext: "從 " + raw + " 推估的產品情境(live 可接真爬頁)",
|
||||||
PainPoints: []string{"找不到適合的", "價格猶豫", "不確定是否適合自己"},
|
PainPoints: []string{"找不到適合的", "價格猶豫", "不確定是否適合自己"},
|
||||||
MatchTags: []string{"好物", "推薦", "踩雷"},
|
MatchTags: []string{"好物", "推薦", "踩雷"},
|
||||||
|
|
@ -248,15 +251,7 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
|
||||||
brief.Tags = tokenize(intent)
|
brief.Tags = tokenize(intent)
|
||||||
}
|
}
|
||||||
brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"}
|
brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"}
|
||||||
seen := map[string]bool{}
|
brief.ScanTerms = planScanTerms(brief)
|
||||||
for _, t := range append(brief.Pains, brief.Tags...) {
|
|
||||||
t = strings.TrimSpace(t)
|
|
||||||
if t == "" || seen[t] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[t] = true
|
|
||||||
brief.ScanTerms = append(brief.ScanTerms, t)
|
|
||||||
}
|
|
||||||
brief.ThemeLabel = truncate(intent, 36)
|
brief.ThemeLabel = truncate(intent, 36)
|
||||||
brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48)
|
brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48)
|
||||||
brief.ResponseStance = "先共鳴再給建議"
|
brief.ResponseStance = "先共鳴再給建議"
|
||||||
|
|
@ -278,27 +273,53 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if devMode {
|
if devMode {
|
||||||
sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
|
storageState, err := s.GetCrawlerSessionToken(ctx, ownerUID)
|
||||||
if err != nil || sess == nil || sess.Token == "" {
|
if err != nil {
|
||||||
return nil, domain.ErrNoCrawlerSession
|
return nil, domain.ErrNoCrawlerSession
|
||||||
}
|
}
|
||||||
path = domain.PathCrawler
|
path = domain.PathCrawler
|
||||||
|
if s.Crawler == nil {
|
||||||
|
return nil, fmt.Errorf("Chrome crawler is not configured")
|
||||||
|
}
|
||||||
|
hits, err := s.Crawler.SearchChrome(ctx, storageState, brief.ScanTerms, 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
|
||||||
}
|
}
|
||||||
|
if s.Provider == nil {
|
||||||
|
return nil, fmt.Errorf("scout search provider is not configured")
|
||||||
|
}
|
||||||
|
hits, err := s.Provider.SearchThreads(ctx, brief.ScanTerms, 5)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
var out []*domain.Post
|
out := make([]*domain.Post, 0, len(hits))
|
||||||
authors := []string{"itchy_days", "new_mom_tw", "remote_worker", "curious_one"}
|
for i, hit := range hits {
|
||||||
limit := len(brief.ScanTerms)
|
text := strings.TrimSpace(hit.Snippet)
|
||||||
if limit > 5 {
|
if text == "" {
|
||||||
limit = 5
|
continue
|
||||||
}
|
}
|
||||||
for i := 0; i < limit; i++ {
|
permalink := canonicalPermalink(hit.URL)
|
||||||
term := brief.ScanTerms[i]
|
if permalink == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
term := matchingTerm(text+" "+hit.Title, brief.ScanTerms)
|
||||||
|
classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms)
|
||||||
|
if classified.classification == domain.ClassificationNoise {
|
||||||
|
continue
|
||||||
|
}
|
||||||
p := &domain.Post{
|
p := &domain.Post{
|
||||||
ID: "sp_" + uuid.NewString()[:10], OwnerUID: ownerUID, BrandID: brief.BrandID,
|
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
|
||||||
Author: authors[i%len(authors)], Text: fmt.Sprintf("有人懂「%s」嗎?最近超煩…", term),
|
OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
|
||||||
SearchTag: term, Opportunity: "可接話分享經驗", OutreachStatus: domain.OutreachNew,
|
SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew,
|
||||||
Score: 55 + (i*11)%40, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
|
Score: classified.score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
|
||||||
MatchReason: "命中掃描詞 " + term, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
|
MatchReason: classified.reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
|
||||||
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
|
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
|
||||||
CreatedAt: now - int64(i)*1000,
|
CreatedAt: now - int64(i)*1000,
|
||||||
}
|
}
|
||||||
|
|
@ -310,6 +331,29 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func matchingTerm(text string, terms []string) string {
|
||||||
|
for _, term := range terms {
|
||||||
|
if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) {
|
||||||
|
return term
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorFromThreadsURL(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, segment := range strings.Split(u.Path, "/") {
|
||||||
|
segment = strings.TrimPrefix(strings.TrimSpace(segment), "@")
|
||||||
|
if segment != "" {
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) {
|
func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) {
|
||||||
return s.Repo.ListPosts(ctx, ownerUID, brandID)
|
return s.Repo.ListPosts(ctx, ownerUID, brandID)
|
||||||
}
|
}
|
||||||
|
|
@ -320,12 +364,7 @@ func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, per
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況…"
|
draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。"
|
||||||
if s.AI != nil {
|
|
||||||
if out, aerr := s.AI.Complete(ctx, "fake", "grok-3", "outreach: "+p.Text); aerr == nil && out != "" {
|
|
||||||
draft = out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.DraftText = draft
|
p.DraftText = draft
|
||||||
p.OutreachStatus = domain.OutreachDrafted
|
p.OutreachStatus = domain.OutreachDrafted
|
||||||
if err := s.Repo.SavePost(ctx, p); err != nil {
|
if err := s.Repo.SavePost(ctx, p); err != nil {
|
||||||
|
|
@ -347,15 +386,11 @@ func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID strin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
|
func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
|
||||||
p, err := s.getPostOwned(ctx, ownerUID, postID)
|
_, err := s.getPostOwned(ctx, ownerUID, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.OutreachStatus = domain.OutreachPublished
|
return nil, fmt.Errorf("manual published status is removed; send through Outbox")
|
||||||
if err := s.Repo.SavePost(ctx, p); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) {
|
func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) {
|
||||||
|
|
@ -370,29 +405,61 @@ func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation)
|
return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
if s.Transport != nil {
|
if s.ReplyQueue == nil {
|
||||||
token := "fake"
|
return nil, fmt.Errorf("Scout reply Outbox is not configured")
|
||||||
if s.Accounts != nil && accountID != "" {
|
}
|
||||||
if acc, aerr := s.Accounts.Get(ctx, accountID); aerr == nil && acc != nil && acc.OwnerUID == ownerUID {
|
mediaID := p.ExternalID
|
||||||
if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" {
|
// A permalink can be resolved to a real Threads media ID before the official
|
||||||
token = t
|
// API sends. This also refreshes any legacy IDs that were not Graph media IDs.
|
||||||
}
|
if strings.TrimSpace(p.Permalink) != "" && s.Crawler != nil {
|
||||||
}
|
state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.ErrNoCrawlerSession
|
||||||
}
|
}
|
||||||
if _, perr := s.Transport.Publish(ctx, studioDomain.PublishRequest{
|
mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
|
||||||
AccessToken: token, AccountID: accountID, Text: text,
|
if err != nil {
|
||||||
}); perr != nil {
|
return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
|
||||||
return nil, perr
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !isNumericMediaID(mediaID) {
|
||||||
|
if s.Crawler == nil {
|
||||||
|
return nil, fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.ErrNoCrawlerSession
|
||||||
|
}
|
||||||
|
mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.ExternalID = mediaID
|
||||||
|
outboxID, err := s.ReplyQueue.QueueExternalReply(ctx, ownerUID, accountID, mediaID, text, "Scout 回覆 · "+truncate(p.Author, 30))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
p.DraftText = text
|
p.DraftText = text
|
||||||
p.OutreachStatus = domain.OutreachPublished
|
p.OutboxID = outboxID
|
||||||
|
p.OutreachStatus = domain.OutreachQueued
|
||||||
if err := s.Repo.SavePost(ctx, p); err != nil {
|
if err := s.Repo.SavePost(ctx, p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isNumericMediaID(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ch := range value {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string) error {
|
func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string) error {
|
||||||
if _, err := s.getPostOwned(ctx, ownerUID, postID); err != nil {
|
if _, err := s.getPostOwned(ctx, ownerUID, postID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -401,7 +468,14 @@ func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RemoveTheme(ctx context.Context, ownerUID int64, themeKey string) error {
|
func (s *Service) RemoveTheme(ctx context.Context, ownerUID int64, themeKey string) error {
|
||||||
return s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey)
|
if err := s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A patrol batch owns both its hits and its research snapshot.
|
||||||
|
if err := s.Repo.DeleteHomework(ctx, ownerUID, themeKey); err != nil && err != domain.ErrNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) {
|
func (s *Service) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) {
|
||||||
|
|
@ -430,22 +504,87 @@ func (s *Service) RemoveHomework(ctx context.Context, ownerUID int64, themeKey s
|
||||||
return s.Repo.DeleteHomework(ctx, ownerUID, themeKey)
|
return s.Repo.DeleteHomework(ctx, ownerUID, themeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SetCrawlerSession(ctx context.Context, ownerUID int64, token string) error {
|
func (s *Service) SetCrawlerSession(ctx context.Context, ownerUID int64, storageState string) error {
|
||||||
|
expiresAt, err := validateStorageState(storageState)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.SessionSecret == "" {
|
||||||
|
return fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
storageStateEnc, err := threadsDomain.Seal(s.SessionSecret, storageState)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := domain.NowNano()
|
||||||
return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{
|
return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{
|
||||||
OwnerUID: ownerUID, Token: token, UpdatedAt: domain.NowNano(),
|
OwnerUID: ownerUID, StorageStateEnc: storageStateEnc, UpdatedAt: now, ExpiresAt: expiresAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCrawlerSessionToken returns Playwright storageState JSON for browser crawl (may be empty).
|
// GetCrawlerSessionToken returns decrypted Playwright storageState JSON for browser crawl.
|
||||||
func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) {
|
func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) {
|
||||||
sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
|
sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if sess == nil {
|
if sess == nil || sess.StorageStateEnc == "" || (sess.ExpiresAt > 0 && sess.ExpiresAt <= domain.NowNano()) {
|
||||||
return "", nil
|
return "", domain.ErrNoCrawlerSession
|
||||||
}
|
}
|
||||||
return sess.Token, nil
|
if s.SessionSecret == "" {
|
||||||
|
return "", fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
storageState, err := threadsDomain.Open(s.SessionSecret, sess.StorageStateEnc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return storageState, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type storageStateCookie struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Expires float64 `json:"expires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStorageState(storageState string) (int64, error) {
|
||||||
|
if len(storageState) > 256*1024 {
|
||||||
|
return 0, fmt.Errorf("%w: storage state exceeds 256KB", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
var state map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(storageState), &state); err != nil || state == nil {
|
||||||
|
return 0, fmt.Errorf("%w: storage state must be a JSON object", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
rawCookies, ok := state["cookies"]
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("%w: cookies required", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
var cookies []storageStateCookie
|
||||||
|
if err := json.Unmarshal(rawCookies, &cookies); err != nil || len(cookies) == 0 {
|
||||||
|
return 0, fmt.Errorf("%w: cookies must be a nonempty array", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
var expiresAt int64
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
domainName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(cookie.Domain)), ".")
|
||||||
|
if !allowedCookieDomain(domainName) {
|
||||||
|
return 0, fmt.Errorf("%w: cookie domain %q is not allowed", domain.ErrValidation, cookie.Domain)
|
||||||
|
}
|
||||||
|
if cookie.Expires > 0 {
|
||||||
|
expires := int64(cookie.Expires * float64(time.Second))
|
||||||
|
if expiresAt == 0 || expires < expiresAt {
|
||||||
|
expiresAt = expires
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowedCookieDomain(domainName string) bool {
|
||||||
|
for _, allowed := range []string{"threads.net", "threads.com", "instagram.com", "facebook.com"} {
|
||||||
|
if domainName == allowed || strings.HasSuffix(domainName, "."+allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ClearCrawlerSession(ctx context.Context, ownerUID int64) error {
|
func (s *Service) ClearCrawlerSession(ctx context.Context, ownerUID int64) error {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/studio/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnavailableTransport is a deliberate non-success path for deployments that
|
||||||
|
// have not configured Threads. It replaces the old successful fake transport.
|
||||||
|
type UnavailableTransport struct {
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUnavailable(reason string) *UnavailableTransport {
|
||||||
|
return &UnavailableTransport{reason: reason}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UnavailableTransport) Publish(context.Context, domain.PublishRequest) (*domain.PublishResult, error) {
|
||||||
|
return nil, fmt.Errorf("Threads publishing is not configured: %s", t.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Transport = (*UnavailableTransport)(nil)
|
||||||
|
|
@ -356,6 +356,43 @@ func TestPL_08_NoUsableAccount(t *testing.T) {
|
||||||
require.ErrorIs(t, err, domain.ErrNoAccount)
|
require.ErrorIs(t, err, domain.ErrNoAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueueExternalReply(t *testing.T) {
|
||||||
|
svc, _, acc := newStudio()
|
||||||
|
uid := int64(4_001_081)
|
||||||
|
addAccount(acc, uid, "acc1", "replying")
|
||||||
|
before := domain.NowNano()
|
||||||
|
bundle, err := svc.QueueExternalReply(context.Background(), uid, "acc1", "123456789", "Thanks for sharing", "External reply")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, uid, bundle.OwnerUID)
|
||||||
|
require.Equal(t, "External reply", bundle.Title)
|
||||||
|
require.Equal(t, domain.OBScheduling, bundle.Status)
|
||||||
|
require.Equal(t, "123456789", bundle.ReplyToMediaID)
|
||||||
|
require.Len(t, bundle.Steps, 1)
|
||||||
|
require.Equal(t, domain.StepReply, bundle.Steps[0].Kind)
|
||||||
|
require.Equal(t, "acc1", bundle.Steps[0].AccountID)
|
||||||
|
require.Equal(t, "123456789", bundle.Steps[0].ReplyTo)
|
||||||
|
require.Equal(t, domain.StepScheduled, bundle.Steps[0].Status)
|
||||||
|
require.InDelta(t, float64(before), float64(bundle.Steps[0].ScheduledAt), float64(2*time.Second))
|
||||||
|
|
||||||
|
persisted, err := svc.GetOutbox(context.Background(), uid, bundle.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, bundle, persisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueExternalReplyValidation(t *testing.T) {
|
||||||
|
svc, _, acc := newStudio()
|
||||||
|
uid := int64(4_001_082)
|
||||||
|
addAccount(acc, uid, "acc1", "replying")
|
||||||
|
|
||||||
|
_, err := svc.QueueExternalReply(context.Background(), uid, "acc1", "media_123", "reply", "")
|
||||||
|
require.ErrorIs(t, err, domain.ErrValidation)
|
||||||
|
_, err = svc.QueueExternalReply(context.Background(), uid, "missing", "123", "reply", "")
|
||||||
|
require.ErrorIs(t, err, domain.ErrNoAccount)
|
||||||
|
acc.byID["acc1"].IsUsable = false
|
||||||
|
_, err = svc.QueueExternalReply(context.Background(), uid, "acc1", "123", "reply", "")
|
||||||
|
require.ErrorIs(t, err, domain.ErrNoAccount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPL_09_RemovePlayKeepsOutbox(t *testing.T) {
|
func TestPL_09_RemovePlayKeepsOutbox(t *testing.T) {
|
||||||
svc, _, acc := newStudio()
|
svc, _, acc := newStudio()
|
||||||
uid := int64(4_001_009)
|
uid := int64(4_001_009)
|
||||||
|
|
|
||||||
|
|
@ -1666,6 +1666,43 @@ func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID,
|
||||||
return bundle, nil
|
return bundle, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueueExternalReply persists an immediate reply to an external Threads media ID.
|
||||||
|
func (s *Service) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (*domain.OutboxBundle, error) {
|
||||||
|
replyToMediaID = strings.TrimSpace(replyToMediaID)
|
||||||
|
if replyToMediaID == "" {
|
||||||
|
return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(replyToMediaID); i++ {
|
||||||
|
if replyToMediaID[i] < '0' || replyToMediaID[i] > '9' {
|
||||||
|
return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accountID == "" {
|
||||||
|
return nil, domain.ErrNoAccount
|
||||||
|
}
|
||||||
|
if s.Accounts != nil {
|
||||||
|
acc, err := s.Accounts.Get(ctx, accountID)
|
||||||
|
if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
|
||||||
|
return nil, domain.ErrNoAccount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowNano()
|
||||||
|
bundle := &domain.OutboxBundle{
|
||||||
|
ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, Title: title,
|
||||||
|
Status: domain.OBScheduling, ReplyToMediaID: replyToMediaID, CreatedAt: now, UpdatedAt: now,
|
||||||
|
Steps: []domain.OutboxStep{{
|
||||||
|
ID: "obstep_" + uuid.NewString()[:10], SortOrder: 0, Kind: domain.StepReply,
|
||||||
|
AccountID: accountID, Text: text, Status: domain.StepScheduled,
|
||||||
|
ScheduledAt: now, ReplyTo: replyToMediaID,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePublishTopicTag(raw string) string {
|
func normalizePublishTopicTag(raw string) string {
|
||||||
s := strings.TrimSpace(raw)
|
s := strings.TrimSpace(raw)
|
||||||
s = strings.TrimPrefix(s, "#")
|
s = strings.TrimPrefix(s, "#")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/threads/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnavailableProvider keeps the service operational when a deployment has not
|
||||||
|
// configured Threads yet. Every provider operation fails explicitly; it never
|
||||||
|
// creates a synthetic OAuth account or token.
|
||||||
|
type UnavailableProvider struct {
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUnavailable(reason string) *UnavailableProvider {
|
||||||
|
return &UnavailableProvider{reason: reason}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UnavailableProvider) Name() string { return "unavailable" }
|
||||||
|
|
||||||
|
func (p *UnavailableProvider) AuthorizeURL(string, string) string { return "" }
|
||||||
|
|
||||||
|
func (p *UnavailableProvider) ExchangeCode(context.Context, string, string) (*domain.TokenPair, error) {
|
||||||
|
return nil, fmt.Errorf("Threads OAuth is not configured: %s", p.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UnavailableProvider) Refresh(context.Context, string) (*domain.TokenPair, error) {
|
||||||
|
return nil, fmt.Errorf("Threads OAuth is not configured: %s", p.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ domain.Provider = (*UnavailableProvider)(nil)
|
||||||
|
|
@ -71,6 +71,9 @@ func (s *Service) OAuthStart(ctx context.Context, ownerUID int64) (authorizeURL,
|
||||||
if ownerUID <= 0 {
|
if ownerUID <= 0 {
|
||||||
return "", "", domain.ErrForbidden
|
return "", "", domain.ErrForbidden
|
||||||
}
|
}
|
||||||
|
if s.Provider == nil || s.Provider.Name() == "unavailable" {
|
||||||
|
return "", "", fmt.Errorf("Threads OAuth is not configured")
|
||||||
|
}
|
||||||
state = randomState()
|
state = randomState()
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
st := &domain.OAuthState{
|
st := &domain.OAuthState{
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package usecase
|
package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apps/backend/internal/module/usage/domain"
|
"apps/backend/internal/module/usage/domain"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlatformGate decides whether platform-paid keys still have capacity.
|
// PlatformGate decides whether platform-paid keys still have capacity.
|
||||||
|
|
@ -94,3 +97,67 @@ type AlwaysAllowPlatform struct{}
|
||||||
|
|
||||||
func (AlwaysAllowPlatform) AllowPlatform(string) bool { return true }
|
func (AlwaysAllowPlatform) AllowPlatform(string) bool { return true }
|
||||||
func (AlwaysAllowPlatform) MarkLimited(time.Time) {}
|
func (AlwaysAllowPlatform) MarkLimited(time.Time) {}
|
||||||
|
|
||||||
|
// RedisPlatformGate coordinates rate limits and provider cooldowns across all
|
||||||
|
// gateway and worker instances. Redis failures deny platform-paid calls rather
|
||||||
|
// than bypassing a shared safety limit.
|
||||||
|
type RedisPlatformGate struct {
|
||||||
|
client *redis.Redis
|
||||||
|
aiPerMin int
|
||||||
|
searchPerMin int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedisPlatformGate(conf redis.RedisConf, aiPerMin, searchPerMin int) *RedisPlatformGate {
|
||||||
|
return &RedisPlatformGate{
|
||||||
|
client: redis.MustNewRedis(conf), aiPerMin: aiPerMin, searchPerMin: searchPerMin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *RedisPlatformGate) MarkLimited(until time.Time) {
|
||||||
|
if g == nil || g.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := "haixun:usage:platform:limited"
|
||||||
|
if until.IsZero() || !until.After(time.Now()) {
|
||||||
|
_, _ = g.client.Del(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seconds := int(time.Until(until).Seconds())
|
||||||
|
if seconds < 1 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
_ = g.client.Setex(key, "1", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *RedisPlatformGate) AllowPlatform(meter string) bool {
|
||||||
|
if g == nil || g.client == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
limit, bucket := g.aiPerMin, "ai"
|
||||||
|
if meter == domain.MeterWebSearch {
|
||||||
|
limit, bucket = g.searchPerMin, "search"
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limited, err := g.client.Exists("haixun:usage:platform:limited")
|
||||||
|
return err == nil && !limited
|
||||||
|
}
|
||||||
|
window := time.Now().UTC().Format("200601021504")
|
||||||
|
result, err := g.client.Eval(`
|
||||||
|
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
||||||
|
local n = redis.call('INCR', KEYS[2])
|
||||||
|
if n == 1 then redis.call('EXPIRE', KEYS[2], 120) end
|
||||||
|
if n > tonumber(ARGV[1]) then return 0 end
|
||||||
|
return 1
|
||||||
|
`, []string{"haixun:usage:platform:limited", fmt.Sprintf("haixun:usage:platform:%s:%s", bucket, window)}, limit)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch v := result.(type) {
|
||||||
|
case int64:
|
||||||
|
return v == 1
|
||||||
|
case string:
|
||||||
|
return v == "1"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import (
|
||||||
fsDomain "apps/backend/internal/module/filestorage/domain"
|
fsDomain "apps/backend/internal/module/filestorage/domain"
|
||||||
"apps/backend/internal/module/filestorage/noop"
|
"apps/backend/internal/module/filestorage/noop"
|
||||||
"apps/backend/internal/module/filestorage/s3store"
|
"apps/backend/internal/module/filestorage/s3store"
|
||||||
|
inspireRepo "apps/backend/internal/module/inspire/repository"
|
||||||
|
inspireUC "apps/backend/internal/module/inspire/usecase"
|
||||||
jobRepo "apps/backend/internal/module/job/repository"
|
jobRepo "apps/backend/internal/module/job/repository"
|
||||||
jobUC "apps/backend/internal/module/job/usecase"
|
jobUC "apps/backend/internal/module/job/usecase"
|
||||||
memberDomain "apps/backend/internal/module/member/domain"
|
memberDomain "apps/backend/internal/module/member/domain"
|
||||||
|
|
@ -23,11 +25,9 @@ import (
|
||||||
memberUC "apps/backend/internal/module/member/usecase"
|
memberUC "apps/backend/internal/module/member/usecase"
|
||||||
notifDomain "apps/backend/internal/module/notification/domain"
|
notifDomain "apps/backend/internal/module/notification/domain"
|
||||||
notifUC "apps/backend/internal/module/notification/usecase"
|
notifUC "apps/backend/internal/module/notification/usecase"
|
||||||
"apps/backend/internal/module/search"
|
|
||||||
inspireRepo "apps/backend/internal/module/inspire/repository"
|
|
||||||
inspireUC "apps/backend/internal/module/inspire/usecase"
|
|
||||||
scoutRepo "apps/backend/internal/module/scout/repository"
|
scoutRepo "apps/backend/internal/module/scout/repository"
|
||||||
scoutUC "apps/backend/internal/module/scout/usecase"
|
scoutUC "apps/backend/internal/module/scout/usecase"
|
||||||
|
"apps/backend/internal/module/search"
|
||||||
studioDomain "apps/backend/internal/module/studio/domain"
|
studioDomain "apps/backend/internal/module/studio/domain"
|
||||||
studioPublish "apps/backend/internal/module/studio/publish"
|
studioPublish "apps/backend/internal/module/studio/publish"
|
||||||
studioRepo "apps/backend/internal/module/studio/repository"
|
studioRepo "apps/backend/internal/module/studio/repository"
|
||||||
|
|
@ -111,18 +111,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
PlatformOpenCode: c.Platform.OpenCodeKey,
|
PlatformOpenCode: c.Platform.OpenCodeKey,
|
||||||
PlatformExa: c.Platform.ExaKey,
|
PlatformExa: c.Platform.ExaKey,
|
||||||
}
|
}
|
||||||
// Dev: empty platform keys still allow PrepareCall for quota path; real LLM needs real key
|
|
||||||
if keyRes.PlatformAI == "" && keyRes.PlatformXAI == "" && keyRes.PlatformOpenCode == "" {
|
|
||||||
keyRes.PlatformAI = "fake-platform-ai"
|
|
||||||
logx.Info("usage: no platform AI keys — using fake-platform-ai for quota only (persona LLM needs real key)")
|
|
||||||
}
|
|
||||||
if keyRes.PlatformExa == "" {
|
|
||||||
keyRes.PlatformExa = "fake-platform-exa"
|
|
||||||
logx.Info("usage: Platform.ExaKey empty — using fake-platform-exa for demo/proxy")
|
|
||||||
}
|
|
||||||
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
|
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
|
||||||
// 平台 key 容量:預設每分鐘 AI 120/搜尋 60;超限或 MarkPlatformLimited 後僅 BYOK 可用
|
usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60)
|
||||||
usageSvc.Gate = usageUC.NewMemoryPlatformGate(120, 60)
|
|
||||||
|
|
||||||
// M4 studio: mon store + publish transport(有 Meta 憑證則真發文/同步)
|
// M4 studio: mon store + publish transport(有 Meta 憑證則真發文/同步)
|
||||||
var pub studioPublish.Transport
|
var pub studioPublish.Transport
|
||||||
|
|
@ -132,19 +122,12 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
mediaSource = &metaMediaBridge{Meta: threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)}
|
mediaSource = &metaMediaBridge{Meta: threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)}
|
||||||
logx.Info("studio: meta publish + own-posts sync enabled")
|
logx.Info("studio: meta publish + own-posts sync enabled")
|
||||||
} else {
|
} else {
|
||||||
pub = studioPublish.NewFake()
|
pub = studioPublish.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
|
||||||
logx.Info("studio: fake publish transport (no Threads app credentials)")
|
logx.Info("studio: Threads publishing disabled until credentials are configured")
|
||||||
}
|
}
|
||||||
aiRegistry := ai.NewRegistry()
|
aiRegistry := ai.NewRegistry()
|
||||||
aiClient := &ai.FakeClient{} // tests / synthetic keys only
|
aiClient, _ := aiRegistry.Client(ai.ProviderXAI)
|
||||||
var searchClient search.Client
|
searchClient := search.NewExa()
|
||||||
if strings.TrimSpace(c.Platform.ExaKey) != "" {
|
|
||||||
searchClient = search.NewExa()
|
|
||||||
logx.Info("search: real Exa client")
|
|
||||||
} else {
|
|
||||||
searchClient = &search.FakeClient{}
|
|
||||||
logx.Info("search: FakeClient (no Platform.ExaKey)")
|
|
||||||
}
|
|
||||||
studioSvc := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
|
studioSvc := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
|
||||||
studioSvc.Accounts = threadsSvc // *threadsUC.Service implements AccountLookup
|
studioSvc.Accounts = threadsSvc // *threadsUC.Service implements AccountLookup
|
||||||
studioSvc.Usage = usageSvc
|
studioSvc.Usage = usageSvc
|
||||||
|
|
@ -171,9 +154,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
}
|
}
|
||||||
scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
|
scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
|
||||||
scoutSvc.Settings = &devModeFromMembers{Members: repo}
|
scoutSvc.Settings = &devModeFromMembers{Members: repo}
|
||||||
scoutSvc.Transport = pub
|
|
||||||
scoutSvc.Accounts = threadsSvc
|
|
||||||
scoutSvc.AI = aiClient
|
scoutSvc.AI = aiClient
|
||||||
|
scoutSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
|
||||||
|
scoutSvc.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey)
|
||||||
|
scoutSvc.SessionSecret = c.Scout.SessionSecret
|
||||||
|
scoutSvc.Crawler = scoutUC.NewHTTPCrawlerProvider(c.Scout.CrawlerEndpoint, c.Scout.CrawlerToken)
|
||||||
// 人設對標帳號分析:可用 Chrome 同步的 crawler session 讀公開頁
|
// 人設對標帳號分析:可用 Chrome 同步的 crawler session 讀公開頁
|
||||||
studioSvc.Crawler = scoutSvc
|
studioSvc.Crawler = scoutSvc
|
||||||
// 靈感 chat 注入真人人設 + 品牌產品目錄
|
// 靈感 chat 注入真人人設 + 品牌產品目錄
|
||||||
|
|
@ -212,6 +197,16 @@ type devModeFromMembers struct {
|
||||||
Members memberDomain.Repository
|
Members memberDomain.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type scoutReplyQueue struct{ Studio *studioUC.Service }
|
||||||
|
|
||||||
|
func (q *scoutReplyQueue) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (string, error) {
|
||||||
|
bundle, err := q.Studio.QueueExternalReply(ctx, ownerUID, accountID, replyToMediaID, text, title)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return bundle.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (bool, error) {
|
func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (bool, error) {
|
||||||
if d == nil || d.Members == nil {
|
if d == nil || d.Members == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|
@ -244,9 +239,6 @@ func findExtensionZip() string {
|
||||||
func newThreads(c config.Config) *threadsUC.Service {
|
func newThreads(c config.Config) *threadsUC.Service {
|
||||||
repo := threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)
|
repo := threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)
|
||||||
secret := c.Auth.AccessSecret
|
secret := c.Auth.AccessSecret
|
||||||
if secret == "" {
|
|
||||||
secret = "dev-token-secret"
|
|
||||||
}
|
|
||||||
web := strings.TrimRight(c.PublicWebBase, "/")
|
web := strings.TrimRight(c.PublicWebBase, "/")
|
||||||
if web == "" {
|
if web == "" {
|
||||||
web = "http://127.0.0.1:5173"
|
web = "http://127.0.0.1:5173"
|
||||||
|
|
@ -273,9 +265,8 @@ func newThreads(c config.Config) *threadsUC.Service {
|
||||||
logx.Error("threads oauth: callback is HTTP — Meta will block (use https PublicAPIBase / PublicWebBase)")
|
logx.Error("threads oauth: callback is HTTP — Meta will block (use https PublicAPIBase / PublicWebBase)")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cb := apiBase + "/api/v1/threads-accounts/oauth/callback"
|
provider = threadsProv.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
|
||||||
provider = &threadsProv.FakeProvider{CallbackBase: cb}
|
logx.Info("threads oauth disabled until credentials are configured")
|
||||||
logx.Info("threads oauth: fake provider (set Platform.ThreadsAppId/Secret for Meta)")
|
|
||||||
}
|
}
|
||||||
svc := threadsUC.New(repo, provider, secret)
|
svc := threadsUC.New(repo, provider, secret)
|
||||||
svc.APIPublicBase = apiBase
|
svc.APIPublicBase = apiBase
|
||||||
|
|
@ -602,7 +593,7 @@ func (b *inspireBrandBridge) ListCatalog(ctx context.Context, ownerUID int64) ([
|
||||||
}
|
}
|
||||||
snap.Products = append(snap.Products, inspireUC.ProductSnapshot{
|
snap.Products = append(snap.Products, inspireUC.ProductSnapshot{
|
||||||
ID: pr.ID, Label: pr.Label, Context: pr.ProductContext,
|
ID: pr.ID, Label: pr.Label, Context: pr.ProductContext,
|
||||||
Tags: append([]string{}, pr.MatchTags...),
|
Tags: append([]string{}, pr.MatchTags...),
|
||||||
Pains: append([]string{}, pr.PainPoints...),
|
Pains: append([]string{}, pr.PainPoints...),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -610,4 +601,3 @@ func (b *inspireBrandBridge) ListCatalog(ctx context.Context, ownerUID int64) ([
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@ func ScoutPostFromDomain(p *scoutDomain.Post) ScoutPostPublic {
|
||||||
Opportunity: p.Opportunity, OutreachStatus: p.OutreachStatus, DraftText: p.DraftText,
|
Opportunity: p.Opportunity, OutreachStatus: p.OutreachStatus, DraftText: p.DraftText,
|
||||||
Score: p.Score, MatchedProductId: p.MatchedProductID, MatchedProductLabel: p.MatchedProductLabel,
|
Score: p.Score, MatchedProductId: p.MatchedProductID, MatchedProductLabel: p.MatchedProductLabel,
|
||||||
MatchReason: p.MatchReason, ScoutMode: p.ScoutMode, IntentSnippet: p.IntentSnippet,
|
MatchReason: p.MatchReason, ScoutMode: p.ScoutMode, IntentSnippet: p.IntentSnippet,
|
||||||
ThemeKey: p.ThemeKey, ThemeLabel: p.ThemeLabel, ScanPath: p.ScanPath, CreatedAt: p.CreatedAt,
|
ThemeKey: p.ThemeKey, ThemeLabel: p.ThemeLabel, ScanPath: p.ScanPath,
|
||||||
|
Classification: p.Classification, Permalink: p.Permalink, CreatedAt: p.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1127,9 +1127,15 @@ type ScoutPostPublic struct {
|
||||||
ThemeKey string `json:"theme_key,optional"`
|
ThemeKey string `json:"theme_key,optional"`
|
||||||
ThemeLabel string `json:"theme_label,optional"`
|
ThemeLabel string `json:"theme_label,optional"`
|
||||||
ScanPath string `json:"scan_path,optional"`
|
ScanPath string `json:"scan_path,optional"`
|
||||||
|
Classification string `json:"classification,optional"`
|
||||||
|
Permalink string `json:"permalink,optional"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScoutScanJobData struct {
|
||||||
|
Job JobPublic `json:"job"`
|
||||||
|
}
|
||||||
|
|
||||||
type ScoutScanReq struct {
|
type ScoutScanReq struct {
|
||||||
Brief ScoutBriefPublic `json:"brief"`
|
Brief ScoutBriefPublic `json:"brief"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ import { buildStorageState } from "./storage-state.js";
|
||||||
|
|
||||||
const CONTENT_SCRIPT_ID = "haixun-bridge";
|
const CONTENT_SCRIPT_ID = "haixun-bridge";
|
||||||
const GO_API_SUCCESS_CODE = 102000;
|
const GO_API_SUCCESS_CODE = 102000;
|
||||||
/** 前端 dev ports → gateway 預設 8888(舊 8890 仍相容) */
|
|
||||||
const DEV_WEB_PORTS = new Set(["3000", "4173", "5173"]);
|
|
||||||
const DEV_API_PORTS = ["8888", "8890"];
|
|
||||||
|
|
||||||
function unwrapGoApiResponse(raw) {
|
function unwrapGoApiResponse(raw) {
|
||||||
if (raw && typeof raw === "object" && "code" in raw) {
|
if (raw && typeof raw === "object" && "code" in raw) {
|
||||||
|
|
@ -36,16 +33,9 @@ function equivalentOrigins(origin) {
|
||||||
return [...origins];
|
return [...origins];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The extension uses the page's /api proxy in every environment. */
|
||||||
* 開發前端 :5173 等 → 後端 gateway :8888;正式部署 same-origin。
|
|
||||||
*/
|
|
||||||
function resolveApiBases(serverUrl) {
|
function resolveApiBases(serverUrl) {
|
||||||
const url = new URL(serverUrl);
|
const url = new URL(serverUrl);
|
||||||
if (DEV_WEB_PORTS.has(url.port)) {
|
|
||||||
const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname;
|
|
||||||
return DEV_API_PORTS.map((p) => `${url.protocol}//${host}:${p}`);
|
|
||||||
}
|
|
||||||
// 遠端 / reverse-proxy:同源即可
|
|
||||||
return [url.origin];
|
return [url.origin];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,4 +383,3 @@ chrome.runtime.onStartup.addListener(() => {
|
||||||
|
|
||||||
// service worker 冷啟動也試一次
|
// service worker 冷啟動也試一次
|
||||||
void bootstrapBridgeFromStorage();
|
void bootstrapBridgeFromStorage();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
# 遠端開 FE(threads-tool-dev.30cm.net)時必須這樣,
|
# 遠端開 FE(threads-tool-dev.30cm.net)時必須這樣,
|
||||||
# 不要寫死 http://127.0.0.1:8888(那會打到使用者自己的電腦)。
|
# 不要寫死 http://127.0.0.1:8888(那會打到使用者自己的電腦)。
|
||||||
#
|
#
|
||||||
# 本機若要直連 gateway(跳過 proxy):
|
# 本機若要直連 gateway(跳過 proxy):須將這個前端 origin 加入
|
||||||
|
# backend 的 CORS_ALLOWED_ORIGINS;HTTPS 頁面不可直連 HTTP gateway。
|
||||||
# VITE_API_BASE=http://127.0.0.1:8888
|
# VITE_API_BASE=http://127.0.0.1:8888
|
||||||
#
|
#
|
||||||
# 走 proxy(推薦):
|
# 走 proxy(推薦):
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
Brand,
|
Brand,
|
||||||
BrandProduct,
|
BrandProduct,
|
||||||
InspirationIdea,
|
InspirationIdea,
|
||||||
|
Job,
|
||||||
InspireAngle,
|
InspireAngle,
|
||||||
InspireChatMessage,
|
InspireChatMessage,
|
||||||
InspireElement,
|
InspireElement,
|
||||||
|
|
@ -180,6 +181,27 @@ function mapScoutPost(raw: Record<string, unknown>): ScoutPost {
|
||||||
intent_snippet: raw.intent_snippet != null ? String(raw.intent_snippet) : undefined,
|
intent_snippet: raw.intent_snippet != null ? String(raw.intent_snippet) : undefined,
|
||||||
theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
|
theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
|
||||||
theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined,
|
theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined,
|
||||||
|
scan_path: raw.scan_path != null ? String(raw.scan_path) : undefined,
|
||||||
|
classification: raw.classification != null ? String(raw.classification) : undefined,
|
||||||
|
permalink: raw.permalink != null ? String(raw.permalink) : undefined,
|
||||||
|
created_at: raw.created_at != null ? Number(raw.created_at) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapScanJob(raw: Record<string, unknown>): Job {
|
||||||
|
return {
|
||||||
|
id: String(raw.id ?? ""),
|
||||||
|
template_type: String(raw.template_type ?? "scout_scan"),
|
||||||
|
status: (raw.status as Job["status"]) || "pending",
|
||||||
|
progress_summary: String(raw.progress_summary ?? ""),
|
||||||
|
progress_percent: Number(raw.progress_percent ?? 0),
|
||||||
|
error: raw.error != null ? String(raw.error) : undefined,
|
||||||
|
ref_id: raw.ref_id != null ? String(raw.ref_id) : undefined,
|
||||||
|
payload: raw.payload != null ? String(raw.payload) : undefined,
|
||||||
|
run_after: raw.run_after != null ? Number(raw.run_after) : undefined,
|
||||||
|
created_at: Number(raw.created_at ?? 0),
|
||||||
|
updated_at: Number(raw.updated_at ?? raw.created_at ?? 0),
|
||||||
|
completed_at: raw.completed_at != null ? Number(raw.completed_at) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,11 +655,13 @@ export function createLiveScout(): ScoutRepo {
|
||||||
return mapBrief(raw);
|
return mapBrief(raw);
|
||||||
},
|
},
|
||||||
async runScanFromBrief(brief) {
|
async runScanFromBrief(brief) {
|
||||||
const data = await apiRequest<{ list: Record<string, unknown>[] }>("/api/v1/scout/scan", {
|
const data = await apiRequest<{ job?: Record<string, unknown> }>("/api/v1/scout/scan", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { brief: briefToBody(brief) },
|
body: { brief: briefToBody(brief) },
|
||||||
});
|
});
|
||||||
return (data.list ?? []).map(mapScoutPost);
|
const job = mapScanJob(data.job ?? {});
|
||||||
|
if (!job.id) throw new Error("海巡任務未建立");
|
||||||
|
return { job };
|
||||||
},
|
},
|
||||||
async getScanContext(brandId, productId) {
|
async getScanContext(brandId, productId) {
|
||||||
const brief = await this.prepareBrief({
|
const brief = await this.prepareBrief({
|
||||||
|
|
|
||||||
|
|
@ -537,8 +537,8 @@ export type ScoutRepo = {
|
||||||
*/
|
*/
|
||||||
deep?: boolean;
|
deep?: boolean;
|
||||||
}): Promise<ScoutRunBrief>;
|
}): Promise<ScoutRunBrief>;
|
||||||
/** 依 brief(含使用者勾選後的 scan_terms)海巡 */
|
/** 依 brief(含使用者勾選後的 scan_terms)建立背景海巡任務。 */
|
||||||
runScanFromBrief(brief: ScoutRunBrief): Promise<ScoutPost[]>;
|
runScanFromBrief(brief: ScoutRunBrief): Promise<{ job: Job }>;
|
||||||
/** @deprecated 用 prepareBrief */
|
/** @deprecated 用 prepareBrief */
|
||||||
getScanContext(brandId: string, productId?: string | null): Promise<ScoutScanContext>;
|
getScanContext(brandId: string, productId?: string | null): Promise<ScoutScanContext>;
|
||||||
/** 列出命中;brandId 空 = 全部(含主題模式) */
|
/** 列出命中;brandId 空 = 全部(含主題模式) */
|
||||||
|
|
@ -548,7 +548,7 @@ export type ScoutRepo = {
|
||||||
brandId: string,
|
brandId: string,
|
||||||
productId?: string | null,
|
productId?: string | null,
|
||||||
extraTerms?: string[],
|
extraTerms?: string[],
|
||||||
): Promise<ScoutPost[]>;
|
): Promise<{ job: Job }>;
|
||||||
draftOutreach(postId: string, personaId?: string): Promise<ScoutPost>;
|
draftOutreach(postId: string, personaId?: string): Promise<ScoutPost>;
|
||||||
skipOutreach(postId: string): Promise<ScoutPost>;
|
skipOutreach(postId: string): Promise<ScoutPost>;
|
||||||
markPublished(postId: string): Promise<ScoutPost>;
|
markPublished(postId: string): Promise<ScoutPost>;
|
||||||
|
|
|
||||||
|
|
@ -647,7 +647,7 @@ export type ScoutTopic = {
|
||||||
preferred_product_id?: string | null;
|
preferred_product_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScoutOutreachStatus = "new" | "drafted" | "published" | "skipped";
|
export type ScoutOutreachStatus = "new" | "drafted" | "queued" | "published" | "skipped";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 探查模式:
|
* 探查模式:
|
||||||
|
|
@ -685,6 +685,14 @@ export type ScoutPost = {
|
||||||
theme_key?: string;
|
theme_key?: string;
|
||||||
/** 分組顯示名:主題/產品/活躍關鍵字 */
|
/** 分組顯示名:主題/產品/活躍關鍵字 */
|
||||||
theme_label?: string;
|
theme_label?: string;
|
||||||
|
/** 掃描來源,例如 api 或 crawler */
|
||||||
|
scan_path?: string;
|
||||||
|
/** 後端分類結果;舊資料或目前契約未提供時省略 */
|
||||||
|
classification?: string;
|
||||||
|
/** 原始 Threads 貼文連結 */
|
||||||
|
permalink?: string;
|
||||||
|
/** 掃描命中的建立時間(UTC unix nanoseconds) */
|
||||||
|
created_at?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1031,6 +1031,7 @@ export const zhTW: MessageDict = {
|
||||||
"scout.skip": "略過",
|
"scout.skip": "略過",
|
||||||
"scout.regen": "再產",
|
"scout.regen": "再產",
|
||||||
"scout.send": "發送",
|
"scout.send": "發送",
|
||||||
|
"scout.resend": "重新送出",
|
||||||
"scout.sending": "發送中…",
|
"scout.sending": "發送中…",
|
||||||
"scout.needAccountBefore": "請先到",
|
"scout.needAccountBefore": "請先到",
|
||||||
"scout.needAccountAfter": "連線可用帳號。",
|
"scout.needAccountAfter": "連線可用帳號。",
|
||||||
|
|
@ -1049,7 +1050,10 @@ export const zhTW: MessageDict = {
|
||||||
"scout.focus": "焦點",
|
"scout.focus": "焦點",
|
||||||
"scout.all": "全部",
|
"scout.all": "全部",
|
||||||
"scout.noWebSummary": "尚無網頁摘要",
|
"scout.noWebSummary": "尚無網頁摘要",
|
||||||
"scout.queue": "佇列 · {n}",
|
"scout.queue": "命中紀錄 · {n}",
|
||||||
|
"scout.valueQueue": "痛點/產品接話 · {n}",
|
||||||
|
"scout.activityQueue": "活躍短回 · {n}",
|
||||||
|
"scout.noMatchesInQueue": "這個佇列暫無命中",
|
||||||
"scout.collapseQueue": "收合佇列",
|
"scout.collapseQueue": "收合佇列",
|
||||||
"scout.expandQueue": "展開佇列",
|
"scout.expandQueue": "展開佇列",
|
||||||
"scout.noOtherPending": "沒有其他待回",
|
"scout.noOtherPending": "沒有其他待回",
|
||||||
|
|
@ -1069,13 +1073,32 @@ export const zhTW: MessageDict = {
|
||||||
"scout.newRunValue": "新批次「{label}」· {n} 則 · 請處理「現在這一則」",
|
"scout.newRunValue": "新批次「{label}」· {n} 則 · 請處理「現在這一則」",
|
||||||
"scout.knowledgeReady": "「{label}」周邊知識已備好 · {n} 則可學",
|
"scout.knowledgeReady": "「{label}」周邊知識已備好 · {n} 則可學",
|
||||||
"scout.patrolFail": "這輪海巡失敗",
|
"scout.patrolFail": "這輪海巡失敗",
|
||||||
|
"scout.loadFail": "海巡資料載入失敗,請重新整理後再試。",
|
||||||
|
"scout.scanQueued": "已建立海巡任務「{label}」,完成後會自動載入命中。",
|
||||||
|
"scout.workerWaiting": "海巡任務仍在等待 worker。請確認 apps/backend worker 正在執行。",
|
||||||
|
"scout.scanReady": "海巡完成,找到 {n} 則待回。",
|
||||||
|
"scout.queued": "已交由 @{who} 的 Outbox 發送佇列處理 · 今日 {done}/{goal}",
|
||||||
|
"scout.scanJob": "海巡任務",
|
||||||
|
"scout.scanInProgress": "正在掃描",
|
||||||
|
"scout.crawlerSessionRequired": "海巡爬蟲需要有效的 Chrome session。請在設定頁同步已登入 Threads 分頁的 session。",
|
||||||
|
"scout.openSettings": "開啟設定",
|
||||||
|
"scout.source": "來源:{source}",
|
||||||
|
"scout.classification": "分類:{classification}",
|
||||||
|
"scout.createdAt": "建立於 {time}",
|
||||||
|
"scout.openPermalink": "開啟原文",
|
||||||
"scout.draftFail": "產草稿失敗",
|
"scout.draftFail": "產草稿失敗",
|
||||||
"scout.skipped": "已略過",
|
"scout.skipped": "已略過",
|
||||||
|
"scout.status.new": "待處理",
|
||||||
|
"scout.status.drafted": "已起草",
|
||||||
|
"scout.status.queued": "發送佇列中",
|
||||||
|
"scout.status.published": "已發送",
|
||||||
|
"scout.status.skipped": "已略過",
|
||||||
"scout.noDraft": "沒有可發送的草稿",
|
"scout.noDraft": "沒有可發送的草稿",
|
||||||
"scout.accountFallback": "帳號",
|
"scout.accountFallback": "帳號",
|
||||||
"scout.sent": "已發送(@{who})· 今日 {done}/{goal}",
|
"scout.sent": "已發送(@{who})· 今日 {done}/{goal}",
|
||||||
"scout.sendFail": "發送失敗",
|
"scout.sendFail": "發送失敗",
|
||||||
"scout.confirmDeletePost": "刪除這則命中?",
|
"scout.confirmDeletePost": "刪除這則命中?",
|
||||||
|
"scout.deletedPost": "已刪除這則命中",
|
||||||
"scout.stanceActivity": "短回 · 養活躍",
|
"scout.stanceActivity": "短回 · 養活躍",
|
||||||
"scout.stanceProduct": "共感 · 可輕帶產品",
|
"scout.stanceProduct": "共感 · 可輕帶產品",
|
||||||
"scout.stanceRelation": "接話 · 建關係",
|
"scout.stanceRelation": "接話 · 建關係",
|
||||||
|
|
@ -2685,6 +2708,7 @@ export const en: MessageDict = {
|
||||||
"scout.skip": "Skip",
|
"scout.skip": "Skip",
|
||||||
"scout.regen": "Regen",
|
"scout.regen": "Regen",
|
||||||
"scout.send": "Send",
|
"scout.send": "Send",
|
||||||
|
"scout.resend": "Resend",
|
||||||
"scout.sending": "Sending…",
|
"scout.sending": "Sending…",
|
||||||
"scout.needAccountBefore": "Connect a usable account under",
|
"scout.needAccountBefore": "Connect a usable account under",
|
||||||
"scout.needAccountAfter": "first.",
|
"scout.needAccountAfter": "first.",
|
||||||
|
|
@ -2703,7 +2727,10 @@ export const en: MessageDict = {
|
||||||
"scout.focus": "Focus",
|
"scout.focus": "Focus",
|
||||||
"scout.all": "All",
|
"scout.all": "All",
|
||||||
"scout.noWebSummary": "No web summaries yet",
|
"scout.noWebSummary": "No web summaries yet",
|
||||||
"scout.queue": "Queue · {n}",
|
"scout.queue": "Matches · {n}",
|
||||||
|
"scout.valueQueue": "Value replies · {n}",
|
||||||
|
"scout.activityQueue": "Activity short replies · {n}",
|
||||||
|
"scout.noMatchesInQueue": "No matches in this queue",
|
||||||
"scout.collapseQueue": "Collapse queue",
|
"scout.collapseQueue": "Collapse queue",
|
||||||
"scout.expandQueue": "Expand queue",
|
"scout.expandQueue": "Expand queue",
|
||||||
"scout.noOtherPending": "No other pending",
|
"scout.noOtherPending": "No other pending",
|
||||||
|
|
@ -2723,13 +2750,32 @@ export const en: MessageDict = {
|
||||||
"scout.newRunValue": "New batch “{label}” · {n} posts · handle “Now this post”",
|
"scout.newRunValue": "New batch “{label}” · {n} posts · handle “Now this post”",
|
||||||
"scout.knowledgeReady": "“{label}” knowledge ready · {n} notes",
|
"scout.knowledgeReady": "“{label}” knowledge ready · {n} notes",
|
||||||
"scout.patrolFail": "This patrol failed",
|
"scout.patrolFail": "This patrol failed",
|
||||||
|
"scout.loadFail": "Scout data could not be loaded. Refresh and try again.",
|
||||||
|
"scout.scanQueued": "Patrol job “{label}” queued. Matches will load automatically when it completes.",
|
||||||
|
"scout.workerWaiting": "The patrol job is still waiting for a worker. Check that apps/backend worker is running.",
|
||||||
|
"scout.scanReady": "Patrol complete. Found {n} pending replies.",
|
||||||
|
"scout.queued": "Queued in @{who}'s Outbox for delivery · today {done}/{goal}",
|
||||||
|
"scout.scanJob": "Patrol job",
|
||||||
|
"scout.scanInProgress": "Scanning",
|
||||||
|
"scout.crawlerSessionRequired": "The patrol crawler needs a valid Chrome session. Sync the session from a signed-in Threads tab in Settings.",
|
||||||
|
"scout.openSettings": "Open Settings",
|
||||||
|
"scout.source": "Source: {source}",
|
||||||
|
"scout.classification": "Class: {classification}",
|
||||||
|
"scout.createdAt": "Created {time}",
|
||||||
|
"scout.openPermalink": "Open original",
|
||||||
"scout.draftFail": "Draft failed",
|
"scout.draftFail": "Draft failed",
|
||||||
"scout.skipped": "Skipped",
|
"scout.skipped": "Skipped",
|
||||||
|
"scout.status.new": "To handle",
|
||||||
|
"scout.status.drafted": "Drafted",
|
||||||
|
"scout.status.queued": "Queued",
|
||||||
|
"scout.status.published": "Sent",
|
||||||
|
"scout.status.skipped": "Skipped",
|
||||||
"scout.noDraft": "No draft to send",
|
"scout.noDraft": "No draft to send",
|
||||||
"scout.accountFallback": "account",
|
"scout.accountFallback": "account",
|
||||||
"scout.sent": "Sent (@{who}) · today {done}/{goal}",
|
"scout.sent": "Sent (@{who}) · today {done}/{goal}",
|
||||||
"scout.sendFail": "Send failed",
|
"scout.sendFail": "Send failed",
|
||||||
"scout.confirmDeletePost": "Delete this hit?",
|
"scout.confirmDeletePost": "Delete this hit?",
|
||||||
|
"scout.deletedPost": "Deleted this hit",
|
||||||
"scout.stanceActivity": "Short reply · activity",
|
"scout.stanceActivity": "Short reply · activity",
|
||||||
"scout.stanceProduct": "Empathy · soft product",
|
"scout.stanceProduct": "Empathy · soft product",
|
||||||
"scout.stanceRelation": "Engage · build rapport",
|
"scout.stanceRelation": "Engage · build rapport",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { PageHeader } from "../components/layout/PageHeader";
|
import { PageHeader } from "../components/layout/PageHeader";
|
||||||
import { Badge, Button, Card, EmptyState, Input, Pager, Select, Textarea } from "../components/ui";
|
import { Badge, Button, Card, EmptyState, Input, Pager, Select, Textarea } from "../components/ui";
|
||||||
|
|
@ -7,7 +7,6 @@ import { useData, useRepos } from "../data/DataContext";
|
||||||
import type {
|
import type {
|
||||||
Brand,
|
Brand,
|
||||||
BrandProduct,
|
BrandProduct,
|
||||||
Persona,
|
|
||||||
ScoutHomeworkRecord,
|
ScoutHomeworkRecord,
|
||||||
ScoutPost,
|
ScoutPost,
|
||||||
ScoutPurpose,
|
ScoutPurpose,
|
||||||
|
|
@ -22,16 +21,78 @@ import {
|
||||||
noteReplyHooks,
|
noteReplyHooks,
|
||||||
formatResearchLink,
|
formatResearchLink,
|
||||||
} from "../lib/mockResearch";
|
} from "../lib/mockResearch";
|
||||||
import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt";
|
|
||||||
import { newId } from "../lib/id";
|
import { newId } from "../lib/id";
|
||||||
import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday";
|
import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday";
|
||||||
import { nowUnixNano } from "../lib/time";
|
import { nowUnixNano } from "../lib/time";
|
||||||
import { useI18n } from "../i18n/I18nContext";
|
import { useI18n } from "../i18n/I18nContext";
|
||||||
|
import { useJobLive } from "../data/JobLiveContext";
|
||||||
|
import { jobStatusLabel, jobStatusTone } from "../lib/jobLabels";
|
||||||
|
import { formatLocalDateTime } from "../lib/time";
|
||||||
|
import type { Job } from "../domain/types";
|
||||||
|
|
||||||
function isPending(p: ScoutPost): boolean {
|
function isPending(p: ScoutPost): boolean {
|
||||||
return p.outreach_status === "new" || p.outreach_status === "drafted";
|
return p.outreach_status === "new" || p.outreach_status === "drafted";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canSend(p: ScoutPost): boolean {
|
||||||
|
return isPending(p) || p.outreach_status === "queued";
|
||||||
|
}
|
||||||
|
|
||||||
|
function outreachStatusLabel(
|
||||||
|
p: ScoutPost,
|
||||||
|
t: (k: string, p?: Record<string, string | number>) => string,
|
||||||
|
): string {
|
||||||
|
return t(`scout.status.${p.outreach_status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchQueue({
|
||||||
|
title,
|
||||||
|
posts,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
currentId,
|
||||||
|
onSelect,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
posts: ScoutPost[];
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
currentId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
t: (k: string, p?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
const pageSize = 8;
|
||||||
|
return (
|
||||||
|
<Card title={title}>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<EmptyState title={t("scout.noMatchesInQueue")} />
|
||||||
|
) : (
|
||||||
|
<div className="hb-stack">
|
||||||
|
{pageSlice(posts, page, pageSize).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`hb-scout-queue-item${currentId === p.id ? " is-active" : ""}`}
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
|
>
|
||||||
|
<span className="hb-scout-queue-item__meta">
|
||||||
|
@{p.author} · {p.search_tag} · {Math.round(p.score)}
|
||||||
|
<Badge tone={isPending(p) ? "brand" : "neutral"}>{outreachStatusLabel(p, t)}</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="hb-scout-queue-item__text">
|
||||||
|
{p.text.slice(0, 72)}
|
||||||
|
{p.text.length > 72 ? "…" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Pager total={posts.length} page={page} pageSize={pageSize} onPageChange={onPageChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function stanceOf(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string {
|
function stanceOf(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string {
|
||||||
if (p.scout_mode === "activity") return t("scout.stanceActivity");
|
if (p.scout_mode === "activity") return t("scout.stanceActivity");
|
||||||
if (p.scout_mode === "product" || p.matched_product_label) return t("scout.stanceProduct");
|
if (p.scout_mode === "product" || p.matched_product_label) return t("scout.stanceProduct");
|
||||||
|
|
@ -143,19 +204,18 @@ function KnowledgeNoteCard({
|
||||||
*/
|
*/
|
||||||
export function ScoutPage() {
|
export function ScoutPage() {
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
const { refresh, tick } = useData();
|
const { tick } = useData();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { jobs: liveJobs, reload: reloadJobs } = useJobLive();
|
||||||
|
|
||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
|
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
|
||||||
const [personas, setPersonas] = useState<Persona[]>([]);
|
|
||||||
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
||||||
const [posts, setPosts] = useState<ScoutPost[]>([]);
|
const [posts, setPosts] = useState<ScoutPost[]>([]);
|
||||||
|
|
||||||
const [purpose, setPurpose] = useState<ScoutPurpose>("value");
|
const [purpose, setPurpose] = useState<ScoutPurpose>("value");
|
||||||
const [intent, setIntent] = useState("");
|
const [intent, setIntent] = useState("");
|
||||||
const [productId, setProductId] = useState("");
|
const [productId, setProductId] = useState("");
|
||||||
const [personaId, setPersonaId] = useState("");
|
|
||||||
const [accountId, setAccountId] = useState("");
|
const [accountId, setAccountId] = useState("");
|
||||||
|
|
||||||
const [goal, setGoal] = useState(8);
|
const [goal, setGoal] = useState(8);
|
||||||
|
|
@ -166,18 +226,19 @@ export function ScoutPage() {
|
||||||
|
|
||||||
const [report, setReport] = useState<ScoutRunBrief | null>(null);
|
const [report, setReport] = useState<ScoutRunBrief | null>(null);
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [valueQueuePage, setValueQueuePage] = useState(1);
|
||||||
const [queuePage, setQueuePage] = useState(1);
|
const [activityQueuePage, setActivityQueuePage] = useState(1);
|
||||||
const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all");
|
const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all");
|
||||||
const QUEUE_PAGE = 8;
|
|
||||||
const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]);
|
const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]);
|
||||||
/** 背景補完整功課(不擋主流程);綁定批次 key */
|
|
||||||
const [homeworkLoadingKey, setHomeworkLoadingKey] = useState<string | null>(null);
|
|
||||||
/** 目前檢視的海巡批次(每次按開始 = 一筆) */
|
/** 目前檢視的海巡批次(每次按開始 = 一筆) */
|
||||||
const [activeRunKey, setActiveRunKey] = useState<string | null>(null);
|
const [activeRunKey, setActiveRunKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [scanJob, setScanJob] = useState<Job | null>(null);
|
||||||
|
const [scanJobThemeKey, setScanJobThemeKey] = useState<string | null>(null);
|
||||||
|
const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = loadScoutToday();
|
const t = loadScoutToday();
|
||||||
|
|
@ -187,16 +248,15 @@ export function ScoutPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const [b, p, hw, prods, postList, acc] = await Promise.all([
|
try {
|
||||||
repos.scout.listBrands(),
|
const [b, hw, prods, postList, acc] = await Promise.all([
|
||||||
repos.personas.list(),
|
repos.scout.listBrands(),
|
||||||
repos.scout.listHomework(),
|
repos.scout.listHomework(),
|
||||||
repos.scout.listAllProducts(),
|
repos.scout.listAllProducts(),
|
||||||
repos.scout.listPosts(),
|
repos.scout.listPosts(),
|
||||||
repos.accounts.list(),
|
repos.accounts.list(),
|
||||||
]);
|
]);
|
||||||
setBrands(b);
|
setBrands(b);
|
||||||
setPersonas(p);
|
|
||||||
setHomeworkList(hw);
|
setHomeworkList(hw);
|
||||||
setAllProducts(prods);
|
setAllProducts(prods);
|
||||||
setPosts(postList);
|
setPosts(postList);
|
||||||
|
|
@ -204,24 +264,14 @@ export function ScoutPage() {
|
||||||
setAccounts(usable);
|
setAccounts(usable);
|
||||||
if (!accountId && usable[0]) setAccountId(usable[0].id);
|
if (!accountId && usable[0]) setAccountId(usable[0].id);
|
||||||
|
|
||||||
const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0));
|
const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||||
if (pending[0]) {
|
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
|
||||||
setCurrentId((cur) => cur || pending[0]!.id);
|
setLoadError("");
|
||||||
setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
|
} catch (e) {
|
||||||
} else if (postList[0]) {
|
setLoadError(e instanceof Error ? e.message : t("scout.loadFail"));
|
||||||
setActiveRunKey((cur) => cur || postRunKey(postList[0]!));
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastValue = hw.find((h) => h.purpose === "value");
|
|
||||||
if (lastValue && !report) {
|
|
||||||
setReport(lastValue.brief);
|
|
||||||
setActiveRunKey((cur) => cur || lastValue.theme_key);
|
|
||||||
if (lastValue.brief.intent) setIntent((prev) => prev || lastValue.brief.intent);
|
|
||||||
const pid = lastValue.brief.product_id || "";
|
|
||||||
if (pid && prods.some((x) => x.id === pid)) setProductId(pid);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [repos, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [repos, t, tick]);
|
||||||
|
|
||||||
const productOptions = useMemo(() => {
|
const productOptions = useMemo(() => {
|
||||||
return allProducts.map((p) => {
|
return allProducts.map((p) => {
|
||||||
|
|
@ -283,28 +333,82 @@ export function ScoutPage() {
|
||||||
[runGroups, activeRunKey],
|
[runGroups, activeRunKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingQueue = useMemo(() => {
|
const runQueue = useMemo(() => {
|
||||||
return posts
|
return posts
|
||||||
.filter(isPending)
|
|
||||||
.filter((p) => !activeRunKey || postRunKey(p) === activeRunKey)
|
.filter((p) => !activeRunKey || postRunKey(p) === activeRunKey)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
.sort((a, b) => Number(isPending(b)) - Number(isPending(a)) || (b.score || 0) - (a.score || 0));
|
||||||
}, [posts, activeRunKey]);
|
}, [posts, activeRunKey]);
|
||||||
|
|
||||||
|
const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]);
|
||||||
|
const valueQueue = useMemo(() => runQueue.filter((p) => p.scout_mode !== "activity"), [runQueue]);
|
||||||
|
const activityQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "activity"), [runQueue]);
|
||||||
|
|
||||||
const current = useMemo(
|
const current = useMemo(
|
||||||
() => posts.find((p) => p.id === currentId) || null,
|
() => posts.find((p) => p.id === currentId) || null,
|
||||||
[posts, currentId],
|
[posts, currentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 切換當前則時帶入草稿;若跳出目前批次則跟著切批次
|
const visibleScanJob = useMemo(
|
||||||
|
() => (scanJob ? liveJobs.find((job) => job.id === scanJob.id) || scanJob : null),
|
||||||
|
[liveJobs, scanJob],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pollScanJob = useEffectEvent(async (jobId: string, cancelled: () => boolean) => {
|
||||||
|
try {
|
||||||
|
await reloadJobs();
|
||||||
|
const job = await repos.jobs.get(jobId);
|
||||||
|
if (cancelled() || !job) return;
|
||||||
|
setScanJob((previous) =>
|
||||||
|
previous?.id === job.id &&
|
||||||
|
previous.status === job.status &&
|
||||||
|
previous.updated_at === job.updated_at
|
||||||
|
? previous
|
||||||
|
: job,
|
||||||
|
);
|
||||||
|
if (job.status === "succeeded") {
|
||||||
|
const list = await repos.scout.listPosts();
|
||||||
|
if (cancelled()) return;
|
||||||
|
setPosts(list);
|
||||||
|
const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey;
|
||||||
|
const pending = list
|
||||||
|
.filter((p) => postRunKey(p) === completedThemeKey && isPending(p))
|
||||||
|
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||||
|
setMessage(t("scout.scanReady", { n: pending.length }));
|
||||||
|
} else if (job.status === "failed" || job.status === "cancelled") {
|
||||||
|
setMessage(job.error || t("scout.patrolFail"));
|
||||||
|
setCrawlerSessionRequired(/crawler.?session|chrome session/i.test(job.error || ""));
|
||||||
|
} else if (
|
||||||
|
job.status === "queued" &&
|
||||||
|
Number(job.created_at || 0) > 0 &&
|
||||||
|
Date.now() - Number(job.created_at) / 1_000_000 > 12_000
|
||||||
|
) {
|
||||||
|
setMessage(t("scout.workerWaiting"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The shared JobLiveContext keeps refreshing; leave the last known status visible.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scanJob || ["succeeded", "failed", "cancelled"].includes(scanJob.status)) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = () => pollScanJob(scanJob.id, () => cancelled);
|
||||||
|
void poll();
|
||||||
|
const id = window.setInterval(() => void poll(), 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [scanJob?.id, scanJob?.status]);
|
||||||
|
|
||||||
|
// 選取貼文只帶入草稿;批次由使用者明確選取,不能反向切換。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setDraftText("");
|
setDraftText("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraftText(current.draft_text || "");
|
setDraftText(current.draft_text || "");
|
||||||
const rk = postRunKey(current);
|
|
||||||
if (rk && rk !== activeRunKey) setActiveRunKey(rk);
|
|
||||||
}, [current?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [current?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
function pickNext(afterId?: string | null, list?: ScoutPost[], runKey?: string | null) {
|
function pickNext(afterId?: string | null, list?: ScoutPost[], runKey?: string | null) {
|
||||||
|
|
@ -327,32 +431,21 @@ export function ScoutPage() {
|
||||||
|
|
||||||
function selectRun(key: string) {
|
function selectRun(key: string) {
|
||||||
setActiveRunKey(key);
|
setActiveRunKey(key);
|
||||||
setQueuePage(1);
|
setValueQueuePage(1);
|
||||||
|
setActivityQueuePage(1);
|
||||||
const hw = homeworkList.find((h) => h.theme_key === key);
|
const hw = homeworkList.find((h) => h.theme_key === key);
|
||||||
if (hw) {
|
if (hw) {
|
||||||
setReport(hw.brief);
|
setReport(hw.brief);
|
||||||
setPurpose(hw.purpose);
|
|
||||||
if (hw.brief.intent) setIntent(hw.brief.intent);
|
|
||||||
if (hw.brief.product_id && allProducts.some((x) => x.id === hw.brief.product_id)) {
|
|
||||||
setProductId(hw.brief.product_id);
|
|
||||||
} else if (hw.purpose === "activity") {
|
|
||||||
setProductId("");
|
|
||||||
}
|
|
||||||
setResearchTier(
|
setResearchTier(
|
||||||
hw.brief.research_notes?.some((n) => (n.tier || "core") === "core") ? "core" : "all",
|
hw.brief.research_notes?.some((n) => (n.tier || "core") === "core") ? "core" : "all",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const sample = posts.find((p) => postRunKey(p) === key);
|
setReport(null);
|
||||||
if (sample?.intent_snippet) setIntent((prev) => prev || sample.intent_snippet || "");
|
|
||||||
if (sample?.matched_product_id) setProductId(sample.matched_product_id);
|
|
||||||
if (sample?.scout_mode === "activity") setPurpose("activity");
|
|
||||||
// 無功課時不硬清 report(避免 thrash);若 key 不同再清
|
|
||||||
if (report?.theme_key && report.theme_key !== key) setReport(null);
|
|
||||||
}
|
}
|
||||||
const pending = posts
|
const pending = posts
|
||||||
.filter((p) => postRunKey(p) === key && isPending(p))
|
.filter((p) => postRunKey(p) === key && isPending(p))
|
||||||
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||||
setCurrentId(pending[0]?.id || posts.find((p) => postRunKey(p) === key)?.id || null);
|
setCurrentId(pending[0]?.id || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeRun(key: string) {
|
async function removeRun(key: string) {
|
||||||
|
|
@ -370,28 +463,13 @@ export function ScoutPage() {
|
||||||
const hw = await repos.scout.listHomework();
|
const hw = await repos.scout.listHomework();
|
||||||
setHomeworkList(hw);
|
setHomeworkList(hw);
|
||||||
if (activeRunKey === key) {
|
if (activeRunKey === key) {
|
||||||
const nextFromList = list.find((p) => postRunKey(p) !== key);
|
setActiveRunKey(null);
|
||||||
const chosen =
|
setReport(null);
|
||||||
(nextFromList ? postRunKey(nextFromList) : null) ||
|
setCurrentId(null);
|
||||||
hw.find((h) => h.theme_key !== key)?.theme_key ||
|
|
||||||
null;
|
|
||||||
setActiveRunKey(chosen);
|
|
||||||
if (chosen) {
|
|
||||||
const nextHw = hw.find((h) => h.theme_key === chosen);
|
|
||||||
setReport(nextHw?.brief || null);
|
|
||||||
const pending = list
|
|
||||||
.filter((p) => postRunKey(p) === chosen && isPending(p))
|
|
||||||
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
||||||
setCurrentId(pending[0]?.id || null);
|
|
||||||
} else {
|
|
||||||
setReport(null);
|
|
||||||
setCurrentId(null);
|
|
||||||
}
|
|
||||||
} else if (currentId && !list.some((p) => p.id === currentId)) {
|
} else if (currentId && !list.some((p) => p.id === currentId)) {
|
||||||
pickNext(currentId, list, activeRunKey);
|
pickNext(currentId, list, activeRunKey);
|
||||||
}
|
}
|
||||||
setMessage(t("scout.deletedRun", { label }));
|
setMessage(t("scout.deletedRun", { label }));
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage(e instanceof Error ? e.message : t("scout.deleteRunFail"));
|
setMessage(e instanceof Error ? e.message : t("scout.deleteRunFail"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -418,7 +496,7 @@ export function ScoutPage() {
|
||||||
}
|
}
|
||||||
setBusy("run");
|
setBusy("run");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setHomeworkLoadingKey(null);
|
setCrawlerSessionRequired(false);
|
||||||
try {
|
try {
|
||||||
const freshProducts = await repos.scout.listAllProducts();
|
const freshProducts = await repos.scout.listAllProducts();
|
||||||
setAllProducts(freshProducts);
|
setAllProducts(freshProducts);
|
||||||
|
|
@ -441,6 +519,10 @@ export function ScoutPage() {
|
||||||
const theme_label = `${baseLabel} · ${runTimeSuffix()}`;
|
const theme_label = `${baseLabel} · ${runTimeSuffix()}`;
|
||||||
const briefSaved: ScoutRunBrief = { ...brief, theme_key, theme_label };
|
const briefSaved: ScoutRunBrief = { ...brief, theme_key, theme_label };
|
||||||
|
|
||||||
|
const { job } = await repos.scout.runScanFromBrief({
|
||||||
|
...briefSaved,
|
||||||
|
scan_terms: briefSaved.scan_terms,
|
||||||
|
});
|
||||||
await repos.scout.saveHomework({
|
await repos.scout.saveHomework({
|
||||||
theme_key,
|
theme_key,
|
||||||
theme_label,
|
theme_label,
|
||||||
|
|
@ -449,80 +531,18 @@ export function ScoutPage() {
|
||||||
created_at: nowUnixNano(),
|
created_at: nowUnixNano(),
|
||||||
});
|
});
|
||||||
setHomeworkList(await repos.scout.listHomework());
|
setHomeworkList(await repos.scout.listHomework());
|
||||||
setReport(briefSaved);
|
setScanJob(job);
|
||||||
setReportOpen(false);
|
setScanJobThemeKey(theme_key);
|
||||||
setActiveRunKey(theme_key);
|
void reloadJobs();
|
||||||
|
setMessage(t("scout.scanQueued", { label: theme_label }));
|
||||||
|
|
||||||
await repos.scout.runScanFromBrief({
|
|
||||||
...briefSaved,
|
|
||||||
scan_terms: briefSaved.scan_terms,
|
|
||||||
});
|
|
||||||
const list = await repos.scout.listPosts();
|
|
||||||
setPosts(list);
|
|
||||||
const pending = list
|
|
||||||
.filter((p) => postRunKey(p) === theme_key && isPending(p))
|
|
||||||
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
||||||
setCurrentId(pending[0]?.id || null);
|
|
||||||
setMessage(
|
|
||||||
purpose === "activity"
|
|
||||||
? t("scout.newRunActivity", { label: theme_label, n: pending.length })
|
|
||||||
: t("scout.newRunValue", { label: theme_label, n: pending.length }),
|
|
||||||
);
|
|
||||||
refresh();
|
|
||||||
setBusy("");
|
|
||||||
|
|
||||||
// ② 痛點模式:背景補周邊知識(綁同一批次 theme_key)
|
|
||||||
if (purpose === "value") {
|
|
||||||
setHomeworkLoadingKey(theme_key);
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const deep = await repos.scout.prepareBrief({
|
|
||||||
intent: text,
|
|
||||||
brandId: selected?.brand_id || null,
|
|
||||||
productId: selected?.id || null,
|
|
||||||
purpose: "value",
|
|
||||||
deep: true,
|
|
||||||
});
|
|
||||||
const deepSaved: ScoutRunBrief = {
|
|
||||||
...deep,
|
|
||||||
theme_key,
|
|
||||||
theme_label,
|
|
||||||
};
|
|
||||||
await repos.scout.saveHomework({
|
|
||||||
theme_key,
|
|
||||||
theme_label,
|
|
||||||
purpose: "value",
|
|
||||||
brief: deepSaved,
|
|
||||||
created_at: nowUnixNano(),
|
|
||||||
});
|
|
||||||
setHomeworkList(await repos.scout.listHomework());
|
|
||||||
// 僅在使用者仍看此批次時更新畫面
|
|
||||||
setReport((prev) =>
|
|
||||||
!prev || prev.theme_key === theme_key ? deepSaved : prev,
|
|
||||||
);
|
|
||||||
const noteCount = deepSaved.research_notes?.length || 0;
|
|
||||||
if (noteCount > 0) {
|
|
||||||
setMessage(t("scout.knowledgeReady", { label: theme_label, n: noteCount }));
|
|
||||||
// 勿在 setState updater 內再 setState(Strict Mode 可能雙重觸發)
|
|
||||||
setActiveRunKey((cur) => {
|
|
||||||
if (cur === theme_key) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setResearchTier("core");
|
|
||||||
setReportOpen(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return cur;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 背景失敗不擋主流程
|
|
||||||
} finally {
|
|
||||||
setHomeworkLoadingKey((k) => (k === theme_key ? null : k));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
|
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
|
||||||
|
setCrawlerSessionRequired(
|
||||||
|
typeof e === "object" && e !== null && "code" in e && (e as { code?: number }).code === 400061,
|
||||||
|
);
|
||||||
|
setBusy("");
|
||||||
|
} finally {
|
||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -537,10 +557,9 @@ export function ScoutPage() {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
setBusy("draft");
|
setBusy("draft");
|
||||||
try {
|
try {
|
||||||
const next = await repos.scout.draftOutreach(current.id, personaId || undefined);
|
const next = await repos.scout.draftOutreach(current.id);
|
||||||
await reloadPosts();
|
await reloadPosts();
|
||||||
setDraftText(next.draft_text || "");
|
setDraftText(next.draft_text || "");
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage(e instanceof Error ? e.message : t("scout.draftFail"));
|
setMessage(e instanceof Error ? e.message : t("scout.draftFail"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -556,8 +575,10 @@ export function ScoutPage() {
|
||||||
await repos.scout.skipOutreach(id);
|
await repos.scout.skipOutreach(id);
|
||||||
const list = await reloadPosts();
|
const list = await reloadPosts();
|
||||||
pickNext(id, list);
|
pickNext(id, list);
|
||||||
|
setTodayDone(bumpScoutTodayDone().done);
|
||||||
setMessage(t("scout.skipped"));
|
setMessage(t("scout.skipped"));
|
||||||
refresh();
|
} catch (e) {
|
||||||
|
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
|
|
@ -570,7 +591,7 @@ export function ScoutPage() {
|
||||||
try {
|
try {
|
||||||
let text = draftText.trim();
|
let text = draftText.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
const d = await repos.scout.draftOutreach(current.id, personaId || undefined);
|
const d = await repos.scout.draftOutreach(current.id);
|
||||||
text = (d.draft_text || "").trim();
|
text = (d.draft_text || "").trim();
|
||||||
setDraftText(text);
|
setDraftText(text);
|
||||||
}
|
}
|
||||||
|
|
@ -580,15 +601,13 @@ export function ScoutPage() {
|
||||||
postId: id,
|
postId: id,
|
||||||
text,
|
text,
|
||||||
accountId: accountId || undefined,
|
accountId: accountId || undefined,
|
||||||
personaId: personaId || undefined,
|
|
||||||
});
|
});
|
||||||
const today = bumpScoutTodayDone(1);
|
|
||||||
setTodayDone(today.done);
|
|
||||||
const list = await reloadPosts();
|
const list = await reloadPosts();
|
||||||
pickNext(id, list);
|
pickNext(id, list);
|
||||||
|
const today = bumpScoutTodayDone();
|
||||||
|
setTodayDone(today.done);
|
||||||
const who = accounts.find((a) => a.id === accountId)?.username || t("scout.accountFallback");
|
const who = accounts.find((a) => a.id === accountId)?.username || t("scout.accountFallback");
|
||||||
setMessage(t("scout.sent", { who, done: today.done, goal }));
|
setMessage(t("scout.queued", { who, done: today.done, goal }));
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage(e instanceof Error ? e.message : t("scout.sendFail"));
|
setMessage(e instanceof Error ? e.message : t("scout.sendFail"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -605,25 +624,34 @@ export function ScoutPage() {
|
||||||
await repos.scout.removePost(id);
|
await repos.scout.removePost(id);
|
||||||
const list = await reloadPosts();
|
const list = await reloadPosts();
|
||||||
pickNext(id, list);
|
pickNext(id, list);
|
||||||
refresh();
|
setMessage(t("scout.deletedPost"));
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(e instanceof Error ? e.message : t("common.error"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressPct = Math.min(100, Math.round((todayDone / Math.max(1, goal)) * 100));
|
const progressPct = Math.min(100, Math.round((todayDone / Math.max(1, goal)) * 100));
|
||||||
const queueRest = pendingQueue.filter((p) => p.id !== currentId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t("scout.title")} />
|
<PageHeader title={t("scout.title")} />
|
||||||
|
|
||||||
|
{loadError ? <EmptyState title={loadError} /> : null}
|
||||||
|
|
||||||
{message ? (
|
{message ? (
|
||||||
<p className="hb-banner-ok" role="status">
|
<p className="hb-banner-ok" role="status">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{crawlerSessionRequired ? (
|
||||||
|
<p className="hb-banner-ok" role="alert">
|
||||||
|
{t("scout.crawlerSessionRequired")} <Link to="/app/settings">{t("scout.openSettings")}</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* ① 今日設定 */}
|
{/* ① 今日設定 */}
|
||||||
<Card title={t("scout.today")}>
|
<Card title={t("scout.today")}>
|
||||||
<div className="hb-stack">
|
<div className="hb-stack">
|
||||||
|
|
@ -730,28 +758,44 @@ export function ScoutPage() {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ② 海巡批次:單一 spar 選單 + 刪除 */}
|
{visibleScanJob ? (
|
||||||
|
<Card title={t("scout.scanJob")}>
|
||||||
|
<div className="hb-inline-badges" role="status" aria-live="polite">
|
||||||
|
<Badge tone={jobStatusTone(visibleScanJob.status)}>
|
||||||
|
{jobStatusLabel(visibleScanJob.status, t)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted">
|
||||||
|
{visibleScanJob.progress_summary || t("scout.scanInProgress")}
|
||||||
|
{visibleScanJob.progress_percent ? ` · ${visibleScanJob.progress_percent}%` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ② 海巡批次:只由使用者選取,不會因 Job 或貼文更新跳動。 */}
|
||||||
{runGroups.length > 0 ? (
|
{runGroups.length > 0 ? (
|
||||||
<Card title={t("scout.runs")}>
|
<Card title={t("scout.runs")}>
|
||||||
<div className="hb-stack">
|
<div className="hb-stack">
|
||||||
<div className="hb-scout-run-row">
|
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||||
<Select
|
{t("scout.runCount", { n: runGroups.length })}
|
||||||
label={t("scout.runCount", { n: runGroups.length })}
|
</p>
|
||||||
aria-label={t("scout.runSelectAria")}
|
<div className="hb-scout-run-list">
|
||||||
value={activeRunKey || ""}
|
{runGroups.map((g) => (
|
||||||
onChange={(e) => {
|
<button
|
||||||
const k = e.target.value;
|
key={g.key}
|
||||||
if (k) selectRun(k);
|
type="button"
|
||||||
}}
|
className={`hb-scout-run-item${activeRunKey === g.key ? " is-active" : ""}`}
|
||||||
>
|
onClick={() => selectRun(g.key)}
|
||||||
{runGroups.map((g) => (
|
>
|
||||||
<option key={g.key} value={g.key}>
|
<span>{g.label}</span>
|
||||||
|
<span className="hb-scout-run-item__meta">
|
||||||
{g.pending > 0 ? t("scout.runPending", { n: g.pending }) : t("scout.runDone")}
|
{g.pending > 0 ? t("scout.runPending", { n: g.pending }) : t("scout.runDone")}
|
||||||
{g.label}
|
|
||||||
{g.total ? t("scout.runTotal", { n: g.total }) : ""}
|
{g.total ? t("scout.runTotal", { n: g.total }) : ""}
|
||||||
</option>
|
</span>
|
||||||
))}
|
</button>
|
||||||
</Select>
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hb-scout-run-row">
|
||||||
{activeRunKey ? (
|
{activeRunKey ? (
|
||||||
<div className="hb-scout-run-row__action">
|
<div className="hb-scout-run-row__action">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -780,6 +824,10 @@ export function ScoutPage() {
|
||||||
<Badge tone="neutral">@{current.author}</Badge>
|
<Badge tone="neutral">@{current.author}</Badge>
|
||||||
<Badge tone="brand">{current.search_tag}</Badge>
|
<Badge tone="brand">{current.search_tag}</Badge>
|
||||||
<Badge tone="neutral">{Math.round(current.score)}</Badge>
|
<Badge tone="neutral">{Math.round(current.score)}</Badge>
|
||||||
|
{current.scan_path ? <Badge tone="neutral">{t("scout.source", { source: current.scan_path })}</Badge> : null}
|
||||||
|
{current.classification ? (
|
||||||
|
<Badge tone="neutral">{t("scout.classification", { classification: current.classification })}</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="hb-scout-now__stance text-muted">
|
<p className="hb-scout-now__stance text-muted">
|
||||||
{stanceOf(current, t)}
|
{stanceOf(current, t)}
|
||||||
|
|
@ -788,6 +836,17 @@ export function ScoutPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="hb-scout-now__text">{current.text}</p>
|
<p className="hb-scout-now__text">{current.text}</p>
|
||||||
|
{current.created_at || current.permalink ? (
|
||||||
|
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
||||||
|
{current.created_at ? t("scout.createdAt", { time: formatLocalDateTime(current.created_at) }) : ""}
|
||||||
|
{current.created_at && current.permalink ? " · " : ""}
|
||||||
|
{current.permalink ? (
|
||||||
|
<a href={current.permalink} target="_blank" rel="noreferrer">
|
||||||
|
{t("scout.openPermalink")}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{current.match_reason ? (
|
{current.match_reason ? (
|
||||||
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
||||||
{current.match_reason}
|
{current.match_reason}
|
||||||
|
|
@ -803,11 +862,11 @@ export function ScoutPage() {
|
||||||
label={t("scout.draft")}
|
label={t("scout.draft")}
|
||||||
value={draftText}
|
value={draftText}
|
||||||
onChange={(e) => setDraftText(e.target.value)}
|
onChange={(e) => setDraftText(e.target.value)}
|
||||||
rows={purpose === "activity" ? 3 : 5}
|
rows={current.scout_mode === "activity" ? 3 : 5}
|
||||||
placeholder={purpose === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
|
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="hb-grid-2">
|
<div>
|
||||||
<Select
|
<Select
|
||||||
label={t("scout.sendAccount")}
|
label={t("scout.sendAccount")}
|
||||||
value={accountId}
|
value={accountId}
|
||||||
|
|
@ -823,26 +882,13 @@ export function ScoutPage() {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
|
||||||
label={t("scout.personaForRegen")}
|
|
||||||
value={personaId}
|
|
||||||
onChange={(e) => setPersonaId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
{personas.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{personaOptionLabel(p)}
|
|
||||||
{!isPersonaReady(p) ? t("scout.notReady") : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hb-wizard-actions">
|
<div className="hb-wizard-actions">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy) || !isPending(current)}
|
||||||
onClick={() => void skipCurrent()}
|
onClick={() => void skipCurrent()}
|
||||||
>
|
>
|
||||||
{busy === "skip" ? "…" : t("scout.skip")}
|
{busy === "skip" ? "…" : t("scout.skip")}
|
||||||
|
|
@ -850,17 +896,21 @@ export function ScoutPage() {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy) || !isPending(current)}
|
||||||
onClick={() => void regenDraft()}
|
onClick={() => void regenDraft()}
|
||||||
>
|
>
|
||||||
{busy === "draft" ? "…" : t("scout.regen")}
|
{busy === "draft" ? "…" : t("scout.regen")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={Boolean(busy) || !accountId}
|
disabled={Boolean(busy) || !accountId || !canSend(current)}
|
||||||
onClick={() => void sendCurrent()}
|
onClick={() => void sendCurrent()}
|
||||||
>
|
>
|
||||||
{busy === "send" ? t("scout.sending") : t("scout.send")}
|
{busy === "send"
|
||||||
|
? t("scout.sending")
|
||||||
|
: current.outreach_status === "queued"
|
||||||
|
? t("scout.resend")
|
||||||
|
: t("scout.send")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -883,9 +933,9 @@ export function ScoutPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ④ 周邊知識 · 綁目前海巡批次 */}
|
{/* ④ 周邊知識 · 綁目前海巡批次 */}
|
||||||
{purpose === "value" &&
|
{report?.mode !== "activity" &&
|
||||||
((report && report.theme_key === activeRunKey) ||
|
report?.theme_key === activeRunKey &&
|
||||||
(homeworkLoadingKey && homeworkLoadingKey === activeRunKey)) ? (
|
report.research_notes?.length ? (
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
report?.theme_label
|
report?.theme_label
|
||||||
|
|
@ -919,12 +969,6 @@ export function ScoutPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{homeworkLoadingKey === activeRunKey ? (
|
|
||||||
<div className="hb-scout-learn-banner is-loading">
|
|
||||||
<p style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t("scout.loadingKnowledge")}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!reportOpen || !report ? (
|
{!reportOpen || !report ? (
|
||||||
report ? (
|
report ? (
|
||||||
report.research_notes && report.research_notes.length > 0 ? (
|
report.research_notes && report.research_notes.length > 0 ? (
|
||||||
|
|
@ -1049,7 +1093,7 @@ export function ScoutPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : homeworkLoadingKey === activeRunKey ? null : (
|
) : (
|
||||||
<p className="hb-scout-learn__intro">{t("scout.noWebSummary")}</p>
|
<p className="hb-scout-learn__intro">{t("scout.noWebSummary")}</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -1058,42 +1102,25 @@ export function ScoutPage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ⑤ 佇列(目前批次其餘待回) */}
|
{/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */}
|
||||||
<Card title={t("scout.queue", { n: queueRest.length })}>
|
<MatchQueue
|
||||||
<div className="hb-wizard-actions" style={{ marginBottom: queueOpen ? "0.65rem" : 0 }}>
|
title={t("scout.valueQueue", { n: valueQueue.length })}
|
||||||
<Button type="button" variant="ghost" onClick={() => setQueueOpen((v) => !v)}>
|
posts={valueQueue}
|
||||||
{queueOpen ? t("scout.collapseQueue") : t("scout.expandQueue")}
|
page={valueQueuePage}
|
||||||
</Button>
|
onPageChange={setValueQueuePage}
|
||||||
</div>
|
currentId={currentId}
|
||||||
{!queueOpen ? null : queueRest.length === 0 ? (
|
onSelect={setCurrentId}
|
||||||
<EmptyState title={t("scout.noOtherPending")} />
|
t={t}
|
||||||
) : (
|
/>
|
||||||
<div className="hb-stack">
|
<MatchQueue
|
||||||
{pageSlice(queueRest, queuePage, QUEUE_PAGE).map((p) => (
|
title={t("scout.activityQueue", { n: activityQueue.length })}
|
||||||
<button
|
posts={activityQueue}
|
||||||
key={p.id}
|
page={activityQueuePage}
|
||||||
type="button"
|
onPageChange={setActivityQueuePage}
|
||||||
className="hb-scout-queue-item"
|
currentId={currentId}
|
||||||
onClick={() => setCurrentId(p.id)}
|
onSelect={setCurrentId}
|
||||||
>
|
t={t}
|
||||||
<span className="hb-scout-queue-item__meta">
|
/>
|
||||||
@{p.author} · {p.search_tag} · {Math.round(p.score)}
|
|
||||||
</span>
|
|
||||||
<span className="hb-scout-queue-item__text">
|
|
||||||
{p.text.slice(0, 72)}
|
|
||||||
{p.text.length > 72 ? "…" : ""}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<Pager
|
|
||||||
total={queueRest.length}
|
|
||||||
page={queuePage}
|
|
||||||
pageSize={QUEUE_PAGE}
|
|
||||||
onPageChange={setQueuePage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,8 @@ export function SettingsPage() {
|
||||||
setExaKey("");
|
setExaKey("");
|
||||||
setMessage(t("settings.searchSaved"));
|
setMessage(t("settings.searchSaved"));
|
||||||
refresh();
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : t("common.error"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2027,7 +2027,41 @@ svg {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 海巡批次:單一 spar */
|
/* 海巡批次 */
|
||||||
|
.hb-scout-run-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-scout-run-item {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid var(--hb-line);
|
||||||
|
border-radius: var(--hb-radius);
|
||||||
|
background: var(--hb-surface-muted);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-scout-run-item:hover,
|
||||||
|
.hb-scout-run-item.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--hb-brand) 55%, var(--hb-line));
|
||||||
|
background: color-mix(in srgb, var(--hb-brand) 9%, var(--hb-surface-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-scout-run-item__meta {
|
||||||
|
color: var(--hb-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.hb-scout-run-row {
|
.hb-scout-run-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
|
@ -2153,6 +2187,11 @@ svg {
|
||||||
border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line));
|
border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hb-scout-queue-item.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--hb-brand) 55%, var(--hb-line));
|
||||||
|
background: color-mix(in srgb, var(--hb-brand) 9%, var(--hb-surface-muted));
|
||||||
|
}
|
||||||
|
|
||||||
.hb-scout-queue-item__meta {
|
.hb-scout-queue-item__meta {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--hb-muted);
|
color: var(--hb-muted);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,25 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, type ProxyOptions } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
const proxy = {
|
||||||
|
// Live API -> gateway
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:8888",
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (gateway) => {
|
||||||
|
// Browser -> Vite is same-origin. Do not forward that Origin to the
|
||||||
|
// private gateway, where it would be mistaken for a direct CORS call.
|
||||||
|
gateway.on("proxyReq", (request) => request.removeHeader("origin"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 頭像等公開物件(MinIO path-style)。nginx 未轉 /haixun-assets 時,
|
||||||
|
// 流量會落到 Vite;由此代轉,避免回 SPA HTML。
|
||||||
|
"/haixun-assets": {
|
||||||
|
target: "http://127.0.0.1:9000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
} satisfies Record<string, string | ProxyOptions>;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|
@ -9,23 +28,12 @@ export default defineConfig({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: false,
|
strictPort: false,
|
||||||
allowedHosts: ["threads-tool-dev.30cm.net", ".30cm.net"],
|
allowedHosts: ["threads-tool-dev.30cm.net", ".30cm.net"],
|
||||||
proxy: {
|
proxy,
|
||||||
// Live API → gateway
|
|
||||||
"/api": {
|
|
||||||
target: "http://127.0.0.1:8888",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
// 頭像等公開物件(MinIO path-style)。nginx 未轉 /haixun-assets 時,
|
|
||||||
// 流量會落到 Vite;由此代轉,避免回 SPA HTML。
|
|
||||||
"/haixun-assets": {
|
|
||||||
target: "http://127.0.0.1:9000",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 4173,
|
port: 4173,
|
||||||
allowedHosts: ["threads-tool-dev.30cm.net", ".30cm.net"],
|
allowedHosts: ["threads-tool-dev.30cm.net", ".30cm.net"],
|
||||||
|
proxy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ server {
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Browser -> nginx is same-origin; gateway should not apply direct-API CORS.
|
||||||
|
proxy_set_header Origin "";
|
||||||
proxy_set_header Authorization $http_authorization;
|
proxy_set_header Authorization $http_authorization;
|
||||||
proxy_set_header X-Member-Authorization $http_x_member_authorization;
|
proxy_set_header X-Member-Authorization $http_x_member_authorization;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue