550 lines
17 KiB
Go
550 lines
17 KiB
Go
|
|
package job
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"haixun-backend/internal/library/clock"
|
|||
|
|
app "haixun-backend/internal/library/errors"
|
|||
|
|
"haixun-backend/internal/library/errors/code"
|
|||
|
|
libprompt "haixun-backend/internal/library/prompt"
|
|||
|
|
"haixun-backend/internal/library/style8d"
|
|||
|
|
joblogic "haixun-backend/internal/logic/job"
|
|||
|
|
"haixun-backend/internal/model/ai/domain/enum"
|
|||
|
|
domai "haixun-backend/internal/model/ai/domain/usecase"
|
|||
|
|
jobentity "haixun-backend/internal/model/job/domain/entity"
|
|||
|
|
jobenum "haixun-backend/internal/model/job/domain/enum"
|
|||
|
|
jobusecase "haixun-backend/internal/model/job/domain/usecase"
|
|||
|
|
personausecase "haixun-backend/internal/model/persona/domain/usecase"
|
|||
|
|
"haixun-backend/internal/response"
|
|||
|
|
"haixun-backend/internal/svc"
|
|||
|
|
"haixun-backend/internal/types"
|
|||
|
|
|
|||
|
|
"github.com/zeromicro/go-zero/rest/httpx"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const workerSecretHeader = "X-Worker-Secret"
|
|||
|
|
|
|||
|
|
type workerJobPath struct {
|
|||
|
|
ID string `path:"id"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type claimWorkerJobReq struct {
|
|||
|
|
WorkerType string `json:"worker_type"`
|
|||
|
|
WorkerID string `json:"worker_id"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type workerJobReq struct {
|
|||
|
|
workerJobPath
|
|||
|
|
WorkerID string `json:"worker_id"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type workerHeartbeatReq struct {
|
|||
|
|
workerJobPath
|
|||
|
|
WorkerID string `json:"worker_id"`
|
|||
|
|
TTLSeconds int `json:"ttl_seconds,optional"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type workerProgressReq struct {
|
|||
|
|
workerJobPath
|
|||
|
|
WorkerID string `json:"worker_id"`
|
|||
|
|
Phase string `json:"phase,optional"`
|
|||
|
|
Summary string `json:"summary,optional"`
|
|||
|
|
Percentage *int `json:"percentage,optional"`
|
|||
|
|
Steps []types.JobStepProgressData `json:"steps,optional"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type workerCompleteReq struct {
|
|||
|
|
workerJobPath
|
|||
|
|
WorkerID string `json:"worker_id"`
|
|||
|
|
Result map[string]interface{} `json:"result,optional"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type workerFailReq struct {
|
|||
|
|
workerJobPath
|
|||
|
|
WorkerID string `json:"worker_id"`
|
|||
|
|
Error string `json:"error"`
|
|||
|
|
Phase string `json:"phase,optional"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type storePersonaStyleProfileReq struct {
|
|||
|
|
ID string `path:"id"`
|
|||
|
|
TenantID string `json:"tenant_id"`
|
|||
|
|
OwnerUID string `json:"owner_uid"`
|
|||
|
|
StyleProfile string `json:"style_profile"`
|
|||
|
|
StyleBenchmark string `json:"style_benchmark,optional"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type workerThreadsAccountSessionReq struct {
|
|||
|
|
ID string `path:"id"`
|
|||
|
|
TenantID string `json:"tenant_id"`
|
|||
|
|
OwnerUID string `json:"owner_uid"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type analyzeStyle8DPostReq struct {
|
|||
|
|
Text string `json:"text"`
|
|||
|
|
Permalink string `json:"permalink,optional"`
|
|||
|
|
LikeCount int `json:"like_count,optional"`
|
|||
|
|
ReplyCount int `json:"reply_count,optional"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type analyzeStyle8DReq struct {
|
|||
|
|
workerJobPath
|
|||
|
|
WorkerID string `json:"worker_id"`
|
|||
|
|
TenantID string `json:"tenant_id"`
|
|||
|
|
OwnerUID string `json:"owner_uid"`
|
|||
|
|
PersonaID string `json:"persona_id"`
|
|||
|
|
ThreadsAccountID string `json:"threads_account_id"`
|
|||
|
|
Username string `json:"username"`
|
|||
|
|
Posts []analyzeStyle8DPostReq `json:"posts"`
|
|||
|
|
Steps []types.JobStepProgressData `json:"steps,optional"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func ClaimWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req claimWorkerJobReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
run, err := svcCtx.Job.ClaimNext(r.Context(), jobusecase.ClaimNextRequest{
|
|||
|
|
WorkerType: req.WorkerType,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
})
|
|||
|
|
if err != nil || run == nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := joblogic.ToJobData(run)
|
|||
|
|
response.Write(r.Context(), w, &data, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func RefreshWorkerJobLockHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req workerHeartbeatReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ttl := req.TTLSeconds
|
|||
|
|
if ttl <= 0 {
|
|||
|
|
ttl = 300
|
|||
|
|
}
|
|||
|
|
err := svcCtx.Job.RefreshRunLock(r.Context(), req.ID, req.WorkerID, ttl)
|
|||
|
|
response.Write(r.Context(), w, map[string]bool{"ok": err == nil}, err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func CheckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req workerJobReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
cancelled, err := svcCtx.Job.IsCancelRequested(r.Context(), req.ID)
|
|||
|
|
response.Write(r.Context(), w, map[string]bool{"cancelled": cancelled}, err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func AckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req workerJobReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
run, err := svcCtx.Job.AcknowledgeCancel(r.Context(), jobusecase.AcknowledgeCancelRequest{
|
|||
|
|
JobID: req.ID,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := joblogic.ToJobData(run)
|
|||
|
|
response.Write(r.Context(), w, &data, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func UpdateWorkerJobProgressHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req workerProgressReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
percentage := -1
|
|||
|
|
if req.Percentage != nil {
|
|||
|
|
percentage = *req.Percentage
|
|||
|
|
}
|
|||
|
|
run, err := svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|||
|
|
JobID: req.ID,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
Phase: req.Phase,
|
|||
|
|
Summary: req.Summary,
|
|||
|
|
Percentage: percentage,
|
|||
|
|
Steps: toEntitySteps(req.Steps),
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := joblogic.ToJobData(run)
|
|||
|
|
response.Write(r.Context(), w, &data, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func CompleteWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req workerCompleteReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
run, err := svcCtx.Job.CompleteRun(r.Context(), jobusecase.CompleteRunRequest{
|
|||
|
|
JobID: req.ID,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
Result: req.Result,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := joblogic.ToJobData(run)
|
|||
|
|
response.Write(r.Context(), w, &data, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func FailWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req workerFailReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
run, err := svcCtx.Job.FailRun(r.Context(), jobusecase.FailRunRequest{
|
|||
|
|
JobID: req.ID,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
Error: req.Error,
|
|||
|
|
Phase: req.Phase,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := joblogic.ToJobData(run)
|
|||
|
|
response.Write(r.Context(), w, &data, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func StorePersonaStyleProfileFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req storePersonaStyleProfileReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if strings.TrimSpace(req.StyleProfile) == "" {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("style_profile is required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
profile := strings.TrimSpace(req.StyleProfile)
|
|||
|
|
benchmark := strings.TrimPrefix(strings.TrimSpace(req.StyleBenchmark), "@")
|
|||
|
|
item, err := svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
|
|||
|
|
TenantID: req.TenantID,
|
|||
|
|
OwnerUID: req.OwnerUID,
|
|||
|
|
PersonaID: req.ID,
|
|||
|
|
Patch: personausecase.PersonaPatch{
|
|||
|
|
StyleProfile: &profile,
|
|||
|
|
StyleBenchmark: &benchmark,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
response.Write(r.Context(), w, map[string]any{"id": item.ID, "update_at": item.UpdateAt}, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func AnalyzeStyle8DFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req analyzeStyle8DReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if strings.TrimSpace(req.WorkerID) == "" {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.Job).InputMissingRequired("worker_id is required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if strings.TrimSpace(req.PersonaID) == "" {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("persona_id is required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if strings.TrimSpace(req.ThreadsAccountID) == "" {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.ThreadsAccount).InputMissingRequired("threads_account_id is required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if len(req.Posts) == 0 {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("posts is required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
credential, err := svcCtx.ThreadsAccount.ResolveWorkerAiCredential(
|
|||
|
|
r.Context(),
|
|||
|
|
req.TenantID,
|
|||
|
|
req.OwnerUID,
|
|||
|
|
req.ThreadsAccountID,
|
|||
|
|
)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
providerID, err := mapWorkerAIProvider(credential.Provider)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
posts := make([]style8d.Post, 0, len(req.Posts))
|
|||
|
|
for _, item := range req.Posts {
|
|||
|
|
text := strings.TrimSpace(item.Text)
|
|||
|
|
if text == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
posts = append(posts, style8d.Post{
|
|||
|
|
Text: text,
|
|||
|
|
Permalink: strings.TrimSpace(item.Permalink),
|
|||
|
|
LikeCount: item.LikeCount,
|
|||
|
|
ReplyCount: item.ReplyCount,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
if len(posts) == 0 {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.Persona).InputInvalidFormat("posts contain no readable text"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
steps := toEntitySteps(req.Steps)
|
|||
|
|
steps = markWorkerStep(steps, "style", jobenum.StepStatusRunning, "AI 正在分析 D1–D8…")
|
|||
|
|
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|||
|
|
JobID: req.ID,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
Phase: "style",
|
|||
|
|
Summary: "AI 正在分析八個風格維度…",
|
|||
|
|
Percentage: 55,
|
|||
|
|
Steps: steps,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
|
|||
|
|
systemPrompt, err := libprompt.Style8DSystem()
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.AI).SysInternal("prompt config load failed"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
result, err := svcCtx.AI.GenerateText(r.Context(), domai.GenerateRequest{
|
|||
|
|
Provider: providerID,
|
|||
|
|
Model: credential.Model,
|
|||
|
|
Credential: domai.Credential{
|
|||
|
|
APIKey: credential.APIKey,
|
|||
|
|
},
|
|||
|
|
System: systemPrompt,
|
|||
|
|
Messages: []domai.Message{
|
|||
|
|
{Role: "user", Content: style8d.BuildUserPrompt(username, posts)},
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
if strings.Contains(err.Error(), "HTTP 401") {
|
|||
|
|
err = app.For(code.AI).SvcThirdParty(
|
|||
|
|
"8D AI 分析授權失敗:目前帳號的研究用 Provider API key 無效或未授權。請到「設定 > 帳號 AI 設定」確認 research provider=" +
|
|||
|
|
credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key",
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parsed, err := style8d.ParseLLMOutput(result.Text)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:"+err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
profile := style8d.BuildStoredProfile(username, posts, parsed)
|
|||
|
|
profileJSON, err := profile.JSON()
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
steps = markWorkerStep(steps, "style", jobenum.StepStatusSucceeded, "8D 風格策略已產生")
|
|||
|
|
steps = markWorkerStep(steps, "store", jobenum.StepStatusRunning, "寫入人設風格策略…")
|
|||
|
|
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|||
|
|
JobID: req.ID,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
Phase: "store",
|
|||
|
|
Summary: "8D 分析完成,寫入人設…",
|
|||
|
|
Percentage: 88,
|
|||
|
|
Steps: steps,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
_, err = svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
|
|||
|
|
TenantID: req.TenantID,
|
|||
|
|
OwnerUID: req.OwnerUID,
|
|||
|
|
PersonaID: req.PersonaID,
|
|||
|
|
Patch: personausecase.PersonaPatch{
|
|||
|
|
StyleProfile: &profileJSON,
|
|||
|
|
StyleBenchmark: &username,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
steps = markWorkerStep(steps, "store", jobenum.StepStatusSucceeded, "8D 策略已寫入人設")
|
|||
|
|
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
|||
|
|
JobID: req.ID,
|
|||
|
|
WorkerID: req.WorkerID,
|
|||
|
|
Phase: "store",
|
|||
|
|
Summary: "8D 策略已寫入人設",
|
|||
|
|
Percentage: 92,
|
|||
|
|
Steps: steps,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
response.Write(r.Context(), w, map[string]any{
|
|||
|
|
"persona_id": req.PersonaID,
|
|||
|
|
"post_count": len(posts),
|
|||
|
|
"style_profile": profileJSON,
|
|||
|
|
"style_benchmark": username,
|
|||
|
|
}, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func GetWorkerThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req workerThreadsAccountSessionReq
|
|||
|
|
if err := httpx.Parse(r, &req); err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
session, err := svcCtx.ThreadsAccount.GetBrowserSession(r.Context(), req.TenantID, req.OwnerUID, req.ID)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Write(r.Context(), w, nil, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
response.Write(r.Context(), w, map[string]any{
|
|||
|
|
"account_id": session.AccountID,
|
|||
|
|
"storage_state": session.StorageState,
|
|||
|
|
"update_at": session.UpdateAt,
|
|||
|
|
}, nil)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func requireWorkerSecret(r *http.Request, svcCtx *svc.ServiceContext) error {
|
|||
|
|
secret := strings.TrimSpace(svcCtx.Config.InternalWorker.Secret)
|
|||
|
|
if secret == "" {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if r.Header.Get(workerSecretHeader) != secret {
|
|||
|
|
return app.For(code.Auth).AuthUnauthorized("invalid worker secret")
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func mapWorkerAIProvider(provider string) (enum.ProviderID, error) {
|
|||
|
|
switch strings.TrimSpace(provider) {
|
|||
|
|
case string(enum.ProviderOpenCode):
|
|||
|
|
return enum.ProviderOpenCode, nil
|
|||
|
|
case string(enum.ProviderXAI):
|
|||
|
|
return enum.ProviderXAI, nil
|
|||
|
|
default:
|
|||
|
|
return "", app.For(code.AI).InputInvalidFormat("worker 8D 分析目前僅支援 opencode-go 與 xai,請在 AI 設定調整 research provider")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func markWorkerStep(steps []jobentity.StepProgress, stepID string, status jobenum.StepStatus, message string) []jobentity.StepProgress {
|
|||
|
|
now := clock.NowUnixNano()
|
|||
|
|
found := false
|
|||
|
|
for i := range steps {
|
|||
|
|
if steps[i].ID != stepID {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
found = true
|
|||
|
|
steps[i].Status = status
|
|||
|
|
steps[i].Message = message
|
|||
|
|
if status == jobenum.StepStatusRunning && steps[i].StartedAt == nil {
|
|||
|
|
steps[i].StartedAt = &now
|
|||
|
|
}
|
|||
|
|
if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
|
|||
|
|
steps[i].EndedAt = &now
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !found {
|
|||
|
|
item := jobentity.StepProgress{ID: stepID, Status: status, Message: message}
|
|||
|
|
if status == jobenum.StepStatusRunning {
|
|||
|
|
item.StartedAt = &now
|
|||
|
|
}
|
|||
|
|
if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
|
|||
|
|
item.EndedAt = &now
|
|||
|
|
}
|
|||
|
|
steps = append(steps, item)
|
|||
|
|
}
|
|||
|
|
return steps
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toEntitySteps(steps []types.JobStepProgressData) []jobentity.StepProgress {
|
|||
|
|
out := make([]jobentity.StepProgress, 0, len(steps))
|
|||
|
|
for _, step := range steps {
|
|||
|
|
out = append(out, jobentity.StepProgress{
|
|||
|
|
ID: step.ID,
|
|||
|
|
Status: jobenum.StepStatus(step.Status),
|
|||
|
|
StartedAt: step.StartedAt,
|
|||
|
|
EndedAt: step.EndedAt,
|
|||
|
|
Message: step.Message,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|