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
|
||
}
|