Version: 0.9.78.dev.260506

This commit is contained in:
Losita
2026-05-06 00:30:08 +08:00
parent 3b6fca44a6
commit 33227e48a7
71 changed files with 13137 additions and 62 deletions

View File

@@ -0,0 +1,329 @@
package adapter
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"time"
taskclassclient "github.com/LoveLosita/smartflow/backend/client/taskclass"
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
taskclasscontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclass"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
var errTaskClassRPCAdapterNotReady = errors.New("taskclassforum task-class rpc adapter is not initialized")
// TaskClassRPCAdapter 负责把 task-class 独立服务适配成计划广场需要的快照端口。
//
// 职责边界:
// 1. 只通过 task-class zrpc 读取/创建任务类,不直连 task_classes / task_items 物理表;
// 2. 只暴露论坛导入/发布需要的白名单快照语义,不透传 schedule 写入能力;
// 3. 论坛业务层只依赖快照端口,后续 task-class 契约继续演进时只改这一层。
type TaskClassRPCAdapter struct {
client *taskclassclient.Client
}
var _ forumsv.TaskClassSnapshotPort = (*TaskClassRPCAdapter)(nil)
// NewTaskClassRPCAdapter 创建基于 task-class zrpc 的论坛快照适配器。
func NewTaskClassRPCAdapter(client *taskclassclient.Client) *TaskClassRPCAdapter {
return &TaskClassRPCAdapter{client: client}
}
// GetOwnedTaskClassSnapshot 读取当前用户自己的 TaskClass并投影为论坛可分享快照。
//
// 职责边界:
// 1. 只读取当前用户可见的单个 TaskClass
// 2. 只返回论坛白名单字段和条目 source id/order/content
// 3. 不透传 embedded_time、status 和任何 schedule 绑定细节。
func (a *TaskClassRPCAdapter) GetOwnedTaskClassSnapshot(ctx context.Context, userID uint64, taskClassID uint64) (*forumsv.TaskClassSnapshot, error) {
if err := a.ensureReady(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
userIDInt, err := toUserID(userID)
if err != nil {
return nil, err
}
taskClassIDInt, err := toTaskClassID(taskClassID)
if err != nil {
return nil, err
}
raw, err := a.client.GetAgentTaskClasses(ctx, taskclasscontracts.AgentTaskClassesRequest{
UserID: userIDInt,
TaskClassIDs: []int{taskClassIDInt},
})
if err != nil {
return nil, err
}
var resp taskclasscontracts.AgentTaskClassesResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, err
}
if len(resp.TaskClasses) == 0 {
return nil, respond.UserTaskClassNotFound
}
snapshot, err := snapshotFromTaskClass(resp.TaskClasses[0])
if err != nil {
return nil, err
}
return &snapshot, nil
}
// CreateTaskClassFromSnapshot 基于论坛模板快照为当前用户创建 task-class 服务里的副本。
//
// 职责边界:
// 1. 只创建 task-class 主体与 items不写 forum_imports
// 2. 所有 item 都作为新记录创建,不沿用原任务条目的 ID
// 3. 不写 schedule导入后仍保持“当前用户自己的未安排副本”语义。
func (a *TaskClassRPCAdapter) CreateTaskClassFromSnapshot(ctx context.Context, userID uint64, snapshot forumsv.TaskClassSnapshot, targetTitle string) (*forumsv.CreatedTaskClass, error) {
if err := a.ensureReady(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
userIDInt, err := toUserID(userID)
if err != nil {
return nil, err
}
title := strings.TrimSpace(targetTitle)
if title == "" {
title = strings.TrimSpace(snapshot.Title)
}
if title == "" || strings.TrimSpace(snapshot.Mode) == "" {
return nil, respond.MissingParam
}
if _, _, err := parseSnapshotDateRange(snapshot.Mode, snapshot.StartDate, snapshot.EndDate); err != nil {
return nil, err
}
raw, err := a.client.AddTaskClass(ctx, buildUpsertTaskClassRequest(userIDInt, title, snapshot))
if err != nil {
return nil, err
}
var created taskclasscontracts.UpsertTaskClassResponse
if err := json.Unmarshal(raw, &created); err != nil {
return nil, err
}
if created.TaskClassID <= 0 {
return nil, respond.InternalError(errors.New("task-class rpc add response missing task_class_id"))
}
return &forumsv.CreatedTaskClass{
TaskClassID: uint64(created.TaskClassID),
Title: title,
}, nil
}
func snapshotFromTaskClass(taskClass taskclasscontracts.AgentTaskClass) (forumsv.TaskClassSnapshot, error) {
items := snapshotItemsFromTaskClassItems(taskClass.Items)
snapshot := forumsv.TaskClassSnapshot{
TaskClassID: uint64(taskClass.ID),
Title: strings.TrimSpace(taskClass.Name),
Mode: strings.TrimSpace(taskClass.Mode),
StartDate: strings.TrimSpace(taskClass.StartDate),
EndDate: strings.TrimSpace(taskClass.EndDate),
SubjectType: strings.TrimSpace(taskClass.SubjectType),
DifficultyLevel: strings.TrimSpace(taskClass.DifficultyLevel),
CognitiveIntensity: strings.TrimSpace(taskClass.CognitiveIntensity),
TotalSlots: taskClass.TotalSlots,
AllowFillerCourse: taskClass.AllowFillerCourse,
Strategy: strings.TrimSpace(taskClass.Strategy),
ExcludedSlots: cloneIntSlice(taskClass.ExcludedSlots),
ExcludedDaysOfWeek: cloneIntSlice(taskClass.ExcludedDaysOfWeek),
StrategyLabels: strategyLabels(taskClass.Strategy),
Items: items,
}
configJSON, err := buildConfigSnapshotJSON(snapshot)
if err != nil {
return forumsv.TaskClassSnapshot{}, err
}
snapshot.ConfigSnapshotJSON = configJSON
return snapshot, nil
}
func snapshotItemsFromTaskClassItems(items []taskclasscontracts.AgentTaskClassItem) []forumsv.TaskClassSnapshotItem {
if len(items) == 0 {
return []forumsv.TaskClassSnapshotItem{}
}
sorted := append([]taskclasscontracts.AgentTaskClassItem(nil), items...)
sort.SliceStable(sorted, func(i, j int) bool {
leftOrder := derefInt(sorted[i].Order)
rightOrder := derefInt(sorted[j].Order)
if leftOrder != rightOrder {
return leftOrder < rightOrder
}
return sorted[i].ID < sorted[j].ID
})
result := make([]forumsv.TaskClassSnapshotItem, 0, len(sorted))
for _, item := range sorted {
content := strings.TrimSpace(item.Content)
if content == "" {
continue
}
result = append(result, forumsv.TaskClassSnapshotItem{
TaskItemID: uint64(item.ID),
Order: derefInt(item.Order),
Content: content,
})
}
return result
}
func buildUpsertTaskClassRequest(userID int, title string, snapshot forumsv.TaskClassSnapshot) taskclasscontracts.UpsertTaskClassRequest {
items := make([]taskclasscontracts.UpsertTaskClassItemConfig, 0, len(snapshot.Items))
sortedItems := append([]forumsv.TaskClassSnapshotItem(nil), snapshot.Items...)
sort.SliceStable(sortedItems, func(i, j int) bool {
if sortedItems[i].Order != sortedItems[j].Order {
return sortedItems[i].Order < sortedItems[j].Order
}
return sortedItems[i].TaskItemID < sortedItems[j].TaskItemID
})
for _, item := range sortedItems {
content := strings.TrimSpace(item.Content)
if content == "" {
continue
}
items = append(items, taskclasscontracts.UpsertTaskClassItemConfig{
Order: item.Order,
Content: content,
})
}
return taskclasscontracts.UpsertTaskClassRequest{
UserID: userID,
Name: title,
StartDate: strings.TrimSpace(snapshot.StartDate),
EndDate: strings.TrimSpace(snapshot.EndDate),
Mode: strings.TrimSpace(snapshot.Mode),
SubjectType: strings.TrimSpace(snapshot.SubjectType),
DifficultyLevel: strings.TrimSpace(snapshot.DifficultyLevel),
CognitiveIntensity: strings.TrimSpace(snapshot.CognitiveIntensity),
Config: taskclasscontracts.UpsertTaskClassConfig{
TotalSlots: snapshot.TotalSlots,
AllowFillerCourse: snapshot.AllowFillerCourse,
Strategy: strings.TrimSpace(snapshot.Strategy),
ExcludedSlots: cloneIntSlice(snapshot.ExcludedSlots),
ExcludedDaysOfWeek: cloneIntSlice(snapshot.ExcludedDaysOfWeek),
},
Items: items,
}
}
func strategyLabels(strategy string) []string {
trimmed := strings.TrimSpace(strategy)
if trimmed == "" {
return []string{}
}
return []string{trimmed}
}
func (a *TaskClassRPCAdapter) ensureReady() error {
if a == nil || a.client == nil {
return errTaskClassRPCAdapterNotReady
}
return nil
}
func toUserID(value uint64) (int, error) {
if value == 0 || value > uint64(maxIntValue()) {
return 0, respond.WrongUserID
}
return int(value), nil
}
func toTaskClassID(value uint64) (int, error) {
if value == 0 || value > uint64(maxIntValue()) {
return 0, respond.WrongTaskClassID
}
return int(value), nil
}
func maxIntValue() int {
return int(^uint(0) >> 1)
}
func derefInt(value *int) int {
if value == nil {
return 0
}
return *value
}
func parseSnapshotDateRange(mode string, startDate string, endDate string) (*time.Time, *time.Time, error) {
parsedStart, err := parseDatePtr(startDate)
if err != nil {
return nil, nil, respond.WrongParamType
}
parsedEnd, err := parseDatePtr(endDate)
if err != nil {
return nil, nil, respond.WrongParamType
}
if strings.TrimSpace(mode) != "auto" {
return parsedStart, parsedEnd, nil
}
if parsedStart == nil || parsedEnd == nil {
return nil, nil, respond.MissingParamForAutoScheduling
}
if parsedStart.After(*parsedEnd) {
return nil, nil, respond.InvalidDateRange
}
return parsedStart, parsedEnd, nil
}
func buildConfigSnapshotJSON(snapshot forumsv.TaskClassSnapshot) (string, error) {
raw, err := json.Marshal(map[string]any{
"mode": snapshot.Mode,
"start_date": snapshot.StartDate,
"end_date": snapshot.EndDate,
"subject_type": snapshot.SubjectType,
"difficulty_level": snapshot.DifficultyLevel,
"cognitive_intensity": snapshot.CognitiveIntensity,
"total_slots": snapshot.TotalSlots,
"allow_filler_course": snapshot.AllowFillerCourse,
"strategy": snapshot.Strategy,
"excluded_slots": cloneIntSlice(snapshot.ExcludedSlots),
"excluded_days_of_week": cloneIntSlice(snapshot.ExcludedDaysOfWeek),
"strategy_labels": append([]string(nil), snapshot.StrategyLabels...),
})
if err != nil {
return "", err
}
return string(raw), nil
}
func parseDatePtr(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.ParseInLocation("2006-01-02", trimmed, time.Local)
if err != nil {
return nil, err
}
return &parsed, nil
}
func cloneIntSlice(values []int) []int {
if len(values) == 0 {
return []int{}
}
return append([]int(nil), values...)
}