Files
smartmate/backend/services/taskclassforum/adapter/taskclass_rpc.go
2026-05-06 00:30:08 +08:00

330 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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...)
}