330 lines
10 KiB
Go
330 lines
10 KiB
Go
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...)
|
||
}
|