Version: 0.9.72.dev.260505
后端: 1.task-class 服务边界落地 - 新增 cmd/task-class 独立进程入口,落地 services/task_class dao/rpc/sv - 新增 gateway/client/taskclass、shared/contracts/taskclass 和 shared/ports task-class port - 将 /api/v1/task-class/* HTTP 门面切到 task-class zrpc,gateway 只保留鉴权、幂等、参数绑定和响应透传 - 保留 task-class 迁移期直写 schedule_events / schedules 权限,维持 insert/apply 与 item 状态更新的本地事务语义 - 修复 task-class 删除已排入日程任务块时 schedules / schedule_events 的外键删除顺序 - 补充 taskClass.rpc 示例配置与阶段 5 文档基线、切流点、残留依赖和 smoke 记录 - 忽略根目录 .tmp 临时烟测产物
This commit is contained in:
76
backend/services/task_class/dao/connect.go
Normal file
76
backend/services/task_class/dao/connect.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OpenDBFromConfig 创建 task-class 服务自己的数据库句柄。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只迁移 task_classes / task_items 这两个 task-class 自有表;
|
||||
// 2. 不迁移 schedule_events / schedules,迁移期只检查它们是否存在;
|
||||
// 3. 迁移期允许 task-class 继续直写 schedule 表,以保留原本本地事务语义。
|
||||
func OpenDBFromConfig() (*gorm.DB, error) {
|
||||
host := viper.GetString("database.host")
|
||||
port := viper.GetString("database.port")
|
||||
user := viper.GetString("database.user")
|
||||
password := viper.GetString("database.password")
|
||||
dbname := viper.GetString("database.dbname")
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
user, password, host, port, dbname,
|
||||
)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = db.AutoMigrate(&model.TaskClass{}, &model.TaskClassItem{}); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate task-class tables failed: %w", err)
|
||||
}
|
||||
if err = ensureRuntimeDependencyTables(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// OpenRedisFromConfig 创建 task-class 服务自己的 Redis 句柄。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责初始化 task-class 列表缓存和幂等链路所需的 Redis client;
|
||||
// 2. 不清理任何业务 key;
|
||||
// 3. Ping 失败直接返回错误,避免服务启动后才暴露缓存不可用。
|
||||
func OpenRedisFromConfig() (*redis.Client, error) {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: viper.GetString("redis.host") + ":" + viper.GetString("redis.port"),
|
||||
Password: viper.GetString("redis.password"),
|
||||
DB: 0,
|
||||
})
|
||||
if _, err := client.Ping(context.Background()).Result(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ensureRuntimeDependencyTables 显式检查 task-class 迁移期仍直写的外部表。
|
||||
//
|
||||
// 说明:
|
||||
// 1. schedule_events / schedules 属于 schedule 服务正式日程域;
|
||||
// 2. 本轮按主人拍板保留 task-class 直写权限,换取 insert/apply 与 item 状态更新的本地事务语义;
|
||||
// 3. 后续若改为 schedule RPC bridge,应先补幂等与补偿,再从这里移除依赖检查。
|
||||
func ensureRuntimeDependencyTables(db *gorm.DB) error {
|
||||
for _, table := range []string{"schedule_events", "schedules"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
return fmt.Errorf("task-class runtime dependency table missing: %s", table)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
350
backend/services/task_class/dao/task_class.go
Normal file
350
backend/services/task_class/dao/task_class.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TaskClassDAO struct {
|
||||
// 这是一个口袋,用来装数据库连接实例
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTaskClassDAO 创建TaskClassDAO实例
|
||||
// NewTaskClassDAO 接收一个 *gorm.DB,并把它塞进结构体的口袋里
|
||||
func NewTaskClassDAO(db *gorm.DB) *TaskClassDAO {
|
||||
return &TaskClassDAO{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) WithTx(tx *gorm.DB) *TaskClassDAO {
|
||||
return &TaskClassDAO{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// AddOrUpdateTaskClass 为指定用户添加/更新任务类(防越权:更新时限定 user_id)
|
||||
func (dao *TaskClassDAO) AddOrUpdateTaskClass(userID int, taskClass *model.TaskClass) (int, error) {
|
||||
// 不信任入参里的 UserID,强制使用当前登录用户
|
||||
taskClass.UserID = &userID
|
||||
|
||||
// 新增:ID == 0 直接插入
|
||||
if taskClass.ID == 0 {
|
||||
if err := dao.db.Create(taskClass).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return taskClass.ID, nil
|
||||
}
|
||||
// 更新:必须同时匹配 id + user_id,否则不会更新任何行(避免覆盖他人数据)
|
||||
tx := dao.db.Model(&model.TaskClass{}).
|
||||
Where("id = ? AND user_id = ?", taskClass.ID, userID).
|
||||
Updates(taskClass)
|
||||
if tx.Error != nil {
|
||||
return 0, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
// 未匹配到记录:要么不存在,要么不属于该用户
|
||||
return 0, respond.UserTaskClassForbidden
|
||||
}
|
||||
return taskClass.ID, nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) AddOrUpdateTaskClassItems(userID int, items []model.TaskClassItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1) 校验这些 items 关联的 task_class(category_id)都属于当前用户
|
||||
categoryIDSet := make(map[int]struct{}, len(items))
|
||||
var categoryIDs []int
|
||||
for _, it := range items {
|
||||
if *it.CategoryID == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if _, ok := categoryIDSet[*it.CategoryID]; !ok {
|
||||
categoryIDSet[*it.CategoryID] = struct{}{}
|
||||
categoryIDs = append(categoryIDs, *it.CategoryID)
|
||||
}
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := dao.db.Model(&model.TaskClass{}).
|
||||
Where("id IN ? AND user_id = ?", categoryIDs, userID).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != int64(len(categoryIDs)) {
|
||||
return respond.UserTaskClassForbidden
|
||||
}
|
||||
|
||||
// 2) 新增与更新分开处理:新增不受影响;更新时限定 category_id(防越权)
|
||||
var toCreate []model.TaskClassItem
|
||||
for _, it := range items {
|
||||
if it.ID == 0 {
|
||||
toCreate = append(toCreate, it)
|
||||
continue
|
||||
}
|
||||
|
||||
tx := dao.db.Model(&model.TaskClassItem{}).
|
||||
Where("id = ? AND category_id IN ?", it.ID, categoryIDs).
|
||||
Updates(map[string]any{
|
||||
"category_id": it.CategoryID,
|
||||
})
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return respond.UserTaskClassForbidden
|
||||
}
|
||||
}
|
||||
|
||||
if len(toCreate) > 0 {
|
||||
if err := dao.db.Create(&toCreate).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Transaction 在一个事务中执行传入的函数,供 service 层复用(自动提交/回滚)
|
||||
// 规则:fn 返回 nil -> commit;fn 返回 error 或 panic -> rollback
|
||||
func (dao *TaskClassDAO) Transaction(fn func(txDAO *TaskClassDAO) error) error {
|
||||
return dao.db.Transaction(func(tx *gorm.DB) error {
|
||||
return fn(NewTaskClassDAO(tx))
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) GetUserTaskClasses(userID int) ([]model.TaskClass, error) {
|
||||
var taskClasses []model.TaskClass
|
||||
err := dao.db.Where("user_id = ?", userID).Find(&taskClasses).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return taskClasses, nil
|
||||
}
|
||||
|
||||
// GetCompleteTaskClassByID 带着 ID 和 UserID 去取,防越权
|
||||
func (dao *TaskClassDAO) GetCompleteTaskClassByID(ctx context.Context, id int, userID int) (*model.TaskClass, error) {
|
||||
var taskClass model.TaskClass
|
||||
|
||||
// 1. 使用 Preload("Items") 自动执行两条 SQL 并组装
|
||||
// SQL A: SELECT * FROM task_classes WHERE id = ? AND user_id = ?
|
||||
// SQL B: SELECT * FROM task_class_items WHERE category_id = (SQL A 的 ID)
|
||||
err := dao.db.WithContext(ctx).
|
||||
Preload("Items").
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
First(&taskClass).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &taskClass, nil
|
||||
}
|
||||
|
||||
// GetCompleteTaskClassesByIDs 批量获取“完整任务类”(含 Items)。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责按 user_id + ids 过滤,保证数据归属安全;
|
||||
// 2. 负责预加载 Items,供智能粗排直接使用;
|
||||
// 3. 不负责排序策略,返回结果顺序由 service 层决定;
|
||||
// 4. 若存在任一 id 不存在或不属于该用户,返回 WrongTaskClassID。
|
||||
func (dao *TaskClassDAO) GetCompleteTaskClassesByIDs(ctx context.Context, userID int, ids []int) ([]model.TaskClass, error) {
|
||||
if len(ids) == 0 {
|
||||
return []model.TaskClass{}, nil
|
||||
}
|
||||
|
||||
// 1. 先做去重与合法值过滤,避免无效 ID 放大数据库压力。
|
||||
uniqueIDs := make([]int, 0, len(ids))
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
uniqueIDs = append(uniqueIDs, id)
|
||||
}
|
||||
if len(uniqueIDs) == 0 {
|
||||
return nil, respond.WrongTaskClassID
|
||||
}
|
||||
|
||||
// 2. 批量查询并预加载任务项。
|
||||
var taskClasses []model.TaskClass
|
||||
err := dao.db.WithContext(ctx).
|
||||
Preload("Items").
|
||||
Where("user_id = ? AND id IN ?", userID, uniqueIDs).
|
||||
Find(&taskClasses).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 数量校验:少一条都视为“存在非法/越权 ID”,统一按业务错误返回。
|
||||
if len(taskClasses) != len(uniqueIDs) {
|
||||
return nil, respond.WrongTaskClassID
|
||||
}
|
||||
return taskClasses, nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) GetTaskClassItemByID(ctx context.Context, id int) (*model.TaskClassItem, error) {
|
||||
var item model.TaskClassItem
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
First(&item).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) GetTaskClassIDByTaskItemID(ctx context.Context, itemID int) (int, error) {
|
||||
var item model.TaskClassItem
|
||||
res := dao.db.WithContext(ctx).
|
||||
Select("category_id").
|
||||
Where("id = ?", itemID).
|
||||
First(&item)
|
||||
if res.Error != nil {
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
return 0, respond.TaskClassItemNotFound
|
||||
}
|
||||
return 0, res.Error
|
||||
}
|
||||
return *item.CategoryID, nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) GetTaskClassUserIDByID(ctx context.Context, taskClassID int) (int, error) {
|
||||
var taskClass model.TaskClass
|
||||
err := dao.db.WithContext(ctx).
|
||||
Select("user_id").
|
||||
Where("id = ?", taskClassID).
|
||||
First(&taskClass).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return *taskClass.UserID, nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) UpdateTaskClassItemEmbeddedTime(ctx context.Context, taskID int, embeddedTime *model.TargetTime) error {
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&model.TaskClassItem{}).
|
||||
Where("id = ?", taskID).
|
||||
Update("embedded_time", embeddedTime).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) DeleteTaskClassItemEmbeddedTime(ctx context.Context, taskID int) error {
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&model.TaskClassItem{}).
|
||||
Where("id = ?", taskID).
|
||||
Update("embedded_time", nil).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) IfTaskClassItemArranged(ctx context.Context, taskID int) (bool, error) {
|
||||
var item model.TaskClassItem
|
||||
err := dao.db.WithContext(ctx).
|
||||
Select("embedded_time").
|
||||
Where("id = ?", taskID).
|
||||
First(&item).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return item.EmbeddedTime != nil, nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) BatchCheckIfTaskClassItemsArranged(ctx context.Context, itemIDs []int) (bool, error) {
|
||||
if len(itemIDs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&model.TaskClassItem{}).
|
||||
Where("id IN ? AND embedded_time IS NOT NULL", itemIDs).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) DeleteTaskClassItemByID(ctx context.Context, id int) error {
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
Delete(&model.TaskClassItem{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) DeleteTaskClassByID(ctx context.Context, id int, userID int) error {
|
||||
// 1. 删除时显式把 user_id 挂到 Model 上,供 GORM 缓存失效插件读取。
|
||||
// 2. 业务层已经完成归属校验,这里仍带上 user_id 条件,避免极端并发下误删其它用户数据。
|
||||
// 3. 若仍存在 task_items 外键依赖,GORM 会返回数据库错误并回滚,本函数不吞掉该错误。
|
||||
res := dao.db.WithContext(ctx).
|
||||
Model(&model.TaskClass{UserID: &userID}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Delete(&model.TaskClass{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return respond.WrongTaskClassID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) BatchUpdateTaskClassItemEmbeddedTime(ctx context.Context, itemIDs []int, updates []*model.TargetTime) error {
|
||||
if len(itemIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(itemIDs) != len(updates) {
|
||||
return errors.New("itemIDs length mismatch updates length")
|
||||
}
|
||||
|
||||
// 单条 SQL 批量更新:UPDATE ... SET embedded_time = CASE id WHEN ? THEN ? ... END WHERE id IN (?)
|
||||
caseSQL := "CASE id"
|
||||
args := make([]any, 0, len(itemIDs)*2)
|
||||
for i, id := range itemIDs {
|
||||
caseSQL += " WHEN ? THEN ?"
|
||||
args = append(args, id, updates[i])
|
||||
}
|
||||
caseSQL += " END"
|
||||
|
||||
res := dao.db.WithContext(ctx).
|
||||
Model(&model.TaskClassItem{}).
|
||||
Where("id IN ?", itemIDs).
|
||||
Update("embedded_time", gorm.Expr(caseSQL, args...))
|
||||
|
||||
return res.Error
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) ValidateTaskItemIDsBelongToTaskClass(ctx context.Context, taskClassID int, itemIDs []int) (bool, error) {
|
||||
if len(itemIDs) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&model.TaskClassItem{}).
|
||||
Where("id IN ? AND category_id = ?", itemIDs, taskClassID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count == int64(len(itemIDs)), nil
|
||||
}
|
||||
|
||||
func (dao *TaskClassDAO) GetTaskClassItemsByIDs(ctx context.Context, itemIDs []int) ([]model.TaskClassItem, error) {
|
||||
var items []model.TaskClassItem
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("id IN ?", itemIDs).
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
83
backend/services/task_class/rpc/errors.go
Normal file
83
backend/services/task_class/rpc/errors.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const taskClassErrorDomain = "smartflow.taskclass"
|
||||
|
||||
// grpcErrorFromServiceError 负责把 task-class 内部错误转换为 gRPC status。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. respond.Response 保留项目内部 status/info,供 gateway 反解;
|
||||
// 2. 未分类错误只暴露通用内部错误,详细信息留在服务日志;
|
||||
// 3. 不在 RPC 层重判业务规则,业务语义仍由 sv/dao 决定。
|
||||
func grpcErrorFromServiceError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var resp respond.Response
|
||||
if errors.As(err, &resp) {
|
||||
return grpcErrorFromResponse(resp)
|
||||
}
|
||||
log.Printf("task-class rpc internal error: %v", err)
|
||||
return status.Error(codes.Internal, "task-class service internal error")
|
||||
}
|
||||
|
||||
func grpcErrorFromResponse(resp respond.Response) error {
|
||||
code := grpcCodeFromRespondStatus(resp.Status)
|
||||
message := strings.TrimSpace(resp.Info)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(resp.Status)
|
||||
}
|
||||
st := status.New(code, message)
|
||||
detail := &errdetails.ErrorInfo{
|
||||
Domain: taskClassErrorDomain,
|
||||
Reason: resp.Status,
|
||||
Metadata: map[string]string{
|
||||
"info": resp.Info,
|
||||
},
|
||||
}
|
||||
withDetails, err := st.WithDetails(detail)
|
||||
if err != nil {
|
||||
return st.Err()
|
||||
}
|
||||
return withDetails.Err()
|
||||
}
|
||||
|
||||
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
|
||||
switch strings.TrimSpace(statusValue) {
|
||||
case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status,
|
||||
respond.ErrUnauthorized.Status, respond.WrongTokenType.Status, respond.UserLoggedOut.Status:
|
||||
return codes.Unauthenticated
|
||||
case respond.UserTaskClassForbidden.Status, respond.TaskClassNotBelongToUser.Status,
|
||||
respond.TaskClassItemNotBelongToUser.Status, respond.CourseNotBelongToUser.Status:
|
||||
return codes.PermissionDenied
|
||||
case respond.UserTaskClassNotFound.Status, respond.TaskClassItemNotFound.Status:
|
||||
return codes.NotFound
|
||||
case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status,
|
||||
respond.WrongUserID.Status, respond.WrongTaskClassID.Status, respond.WrongTaskID.Status,
|
||||
respond.InvalidSectionNumber.Status, respond.InvalidWeekOrDayOfWeek.Status,
|
||||
respond.InvalidSectionRange.Status, respond.MissingParamForAutoScheduling.Status,
|
||||
respond.InvalidDateRange.Status, respond.TaskClassModeNotAuto.Status,
|
||||
respond.TimeNotEnoughForAutoScheduling.Status, respond.TaskClassItemNotBelongToTaskClass.Status,
|
||||
respond.TaskClassItemTryingToInsertOutOfTimeRange.Status, respond.TaskClassItemAlreadyArranged.Status,
|
||||
respond.CourseAlreadyEmbeddedByOtherTaskBlock.Status, respond.ScheduleConflict.Status,
|
||||
respond.WrongCourseID.Status, respond.CourseTimeNotMatch.Status, respond.InsertCourseTwice.Status,
|
||||
respond.WeekOutOfRange.Status, respond.WrongScheduleEventID.Status,
|
||||
respond.TargetScheduleNotHaveEmbeddedTask.Status, respond.TargetTaskNotEmbeddedInAnySchedule.Status,
|
||||
respond.TimeOutOfRangeOfThisSemester.Status:
|
||||
return codes.InvalidArgument
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
|
||||
return codes.Internal
|
||||
}
|
||||
return codes.InvalidArgument
|
||||
}
|
||||
221
backend/services/task_class/rpc/handler.go
Normal file
221
backend/services/task_class/rpc/handler.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/services/task_class/rpc/pb"
|
||||
taskclasssv "github.com/LoveLosita/smartflow/backend/services/task_class/sv"
|
||||
taskclasscontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclass"
|
||||
)
|
||||
|
||||
const (
|
||||
taskClassCreate = 0
|
||||
taskClassUpdate = 1
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
pb.UnimplementedTaskClassServer
|
||||
svc *taskclasssv.TaskClassService
|
||||
}
|
||||
|
||||
func NewHandler(svc *taskclasssv.TaskClassService) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// Ping 供调用方在启动期确认 task-class zrpc 已可用。
|
||||
func (h *Handler) Ping(ctx context.Context, req *pb.StatusResponse) (*pb.StatusResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.StatusResponse{}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) AddTaskClass(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.UpsertTaskClassRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
err := h.svc.AddOrUpdateTaskClass(ctx, toModelTaskClassRequest(contractReq), contractReq.UserID, taskClassCreate, 0)
|
||||
return jsonResponse(nil, err)
|
||||
}
|
||||
|
||||
func (h *Handler) ListTaskClasses(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.UserRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
data, err := h.svc.GetUserTaskClassInfos(ctx, contractReq.UserID)
|
||||
return jsonResponse(data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) GetTaskClass(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.GetTaskClassRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
data, err := h.svc.GetUserCompleteTaskClass(ctx, contractReq.UserID, contractReq.TaskClassID)
|
||||
return jsonResponse(data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateTaskClass(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.UpsertTaskClassRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
err := h.svc.AddOrUpdateTaskClass(ctx, toModelTaskClassRequest(contractReq), contractReq.UserID, taskClassUpdate, contractReq.TaskClassID)
|
||||
return jsonResponse(nil, err)
|
||||
}
|
||||
|
||||
func (h *Handler) InsertTaskClassItemIntoSchedule(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.InsertTaskClassItemIntoScheduleRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
err := h.svc.AddTaskClassItemIntoSchedule(ctx, toModelInsertTaskClassItemRequest(contractReq), contractReq.UserID, contractReq.TaskItemID)
|
||||
return jsonResponse(nil, err)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTaskClassItem(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.DeleteTaskClassItemRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
err := h.svc.DeleteTaskClassItem(ctx, contractReq.UserID, contractReq.TaskItemID)
|
||||
return jsonResponse(nil, err)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteTaskClass(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.DeleteTaskClassRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
err := h.svc.DeleteTaskClass(ctx, contractReq.UserID, contractReq.TaskClassID)
|
||||
return jsonResponse(nil, err)
|
||||
}
|
||||
|
||||
func (h *Handler) ApplyBatchIntoSchedule(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contractReq taskclasscontracts.ApplyBatchIntoScheduleRequest
|
||||
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
|
||||
return nil, grpcErrorFromServiceError(respond.WrongParamType)
|
||||
}
|
||||
err := h.svc.BatchApplyPlans(ctx, contractReq.TaskClassID, contractReq.UserID, toModelBatchRequest(contractReq))
|
||||
return jsonResponse(nil, err)
|
||||
}
|
||||
|
||||
func (h *Handler) ensureReady(req any) error {
|
||||
if h == nil || h.svc == nil {
|
||||
return grpcErrorFromServiceError(errors.New("task-class service dependency not initialized"))
|
||||
}
|
||||
if req == nil {
|
||||
return grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonResponse(value any, err error) (*pb.JSONResponse, error) {
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.JSONResponse{DataJson: raw}, nil
|
||||
}
|
||||
|
||||
func toModelTaskClassRequest(req taskclasscontracts.UpsertTaskClassRequest) *model.UserAddTaskClassRequest {
|
||||
items := make([]model.UserAddTaskClassItemRequest, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
items = append(items, model.UserAddTaskClassItemRequest{
|
||||
ID: item.ID,
|
||||
Order: item.Order,
|
||||
Content: item.Content,
|
||||
EmbeddedTime: toModelTargetTime(item.EmbeddedTime),
|
||||
})
|
||||
}
|
||||
return &model.UserAddTaskClassRequest{
|
||||
Name: req.Name,
|
||||
StartDate: req.StartDate,
|
||||
EndDate: req.EndDate,
|
||||
Mode: req.Mode,
|
||||
SubjectType: req.SubjectType,
|
||||
DifficultyLevel: req.DifficultyLevel,
|
||||
CognitiveIntensity: req.CognitiveIntensity,
|
||||
Config: model.UserAddTaskClassConfig{
|
||||
TotalSlots: req.Config.TotalSlots,
|
||||
AllowFillerCourse: req.Config.AllowFillerCourse,
|
||||
Strategy: req.Config.Strategy,
|
||||
ExcludedSlots: append([]int(nil), req.Config.ExcludedSlots...),
|
||||
ExcludedDaysOfWeek: append([]int(nil), req.Config.ExcludedDaysOfWeek...),
|
||||
},
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func toModelTargetTime(value *taskclasscontracts.TargetTime) *model.TargetTime {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.TargetTime{
|
||||
Week: value.Week,
|
||||
DayOfWeek: value.DayOfWeek,
|
||||
SectionFrom: value.SectionFrom,
|
||||
SectionTo: value.SectionTo,
|
||||
}
|
||||
}
|
||||
|
||||
func toModelInsertTaskClassItemRequest(req taskclasscontracts.InsertTaskClassItemIntoScheduleRequest) *model.UserInsertTaskClassItemToScheduleRequest {
|
||||
return &model.UserInsertTaskClassItemToScheduleRequest{
|
||||
Week: req.Week,
|
||||
DayOfWeek: req.DayOfWeek,
|
||||
StartSection: req.StartSection,
|
||||
EndSection: req.EndSection,
|
||||
EmbedCourseEventID: req.EmbedCourseEventID,
|
||||
}
|
||||
}
|
||||
|
||||
func toModelBatchRequest(req taskclasscontracts.ApplyBatchIntoScheduleRequest) *model.UserInsertTaskClassItemToScheduleRequestBatch {
|
||||
items := make([]model.SingleTaskClassItem, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
items = append(items, model.SingleTaskClassItem{
|
||||
TaskItemID: item.TaskItemID,
|
||||
Week: item.Week,
|
||||
DayOfWeek: item.DayOfWeek,
|
||||
StartSection: item.StartSection,
|
||||
EndSection: item.EndSection,
|
||||
EmbedCourseEventID: item.EmbedCourseEventID,
|
||||
})
|
||||
}
|
||||
return &model.UserInsertTaskClassItemToScheduleRequestBatch{
|
||||
TaskClassID: req.TaskClassID,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
39
backend/services/task_class/rpc/pb/task_class.pb.go
Normal file
39
backend/services/task_class/rpc/pb/task_class.pb.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package pb
|
||||
|
||||
import proto "github.com/golang/protobuf/proto"
|
||||
|
||||
var _ = proto.Marshal
|
||||
|
||||
const _ = proto.ProtoPackageIsVersion3
|
||||
|
||||
type JSONRequest struct {
|
||||
PayloadJson []byte `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *JSONRequest) Reset() { *m = JSONRequest{} }
|
||||
func (m *JSONRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*JSONRequest) ProtoMessage() {}
|
||||
|
||||
type JSONResponse struct {
|
||||
DataJson []byte `protobuf:"bytes,1,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *JSONResponse) Reset() { *m = JSONResponse{} }
|
||||
func (m *JSONResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*JSONResponse) ProtoMessage() {}
|
||||
|
||||
type StatusResponse struct {
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *StatusResponse) Reset() { *m = StatusResponse{} }
|
||||
func (m *StatusResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*StatusResponse) ProtoMessage() {}
|
||||
191
backend/services/task_class/rpc/pb/task_class_grpc.pb.go
Normal file
191
backend/services/task_class/rpc/pb/task_class_grpc.pb.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskClass_Ping_FullMethodName = "/smartflow.taskclass.TaskClass/Ping"
|
||||
TaskClass_AddTaskClass_FullMethodName = "/smartflow.taskclass.TaskClass/AddTaskClass"
|
||||
TaskClass_ListTaskClasses_FullMethodName = "/smartflow.taskclass.TaskClass/ListTaskClasses"
|
||||
TaskClass_GetTaskClass_FullMethodName = "/smartflow.taskclass.TaskClass/GetTaskClass"
|
||||
TaskClass_UpdateTaskClass_FullMethodName = "/smartflow.taskclass.TaskClass/UpdateTaskClass"
|
||||
TaskClass_InsertTaskClassItemIntoSchedule_FullMethodName = "/smartflow.taskclass.TaskClass/InsertTaskClassItemIntoSchedule"
|
||||
TaskClass_DeleteTaskClassItem_FullMethodName = "/smartflow.taskclass.TaskClass/DeleteTaskClassItem"
|
||||
TaskClass_DeleteTaskClass_FullMethodName = "/smartflow.taskclass.TaskClass/DeleteTaskClass"
|
||||
TaskClass_ApplyBatchIntoSchedule_FullMethodName = "/smartflow.taskclass.TaskClass/ApplyBatchIntoSchedule"
|
||||
)
|
||||
|
||||
type TaskClassClient interface {
|
||||
Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error)
|
||||
AddTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
ListTaskClasses(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
GetTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
UpdateTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
InsertTaskClassItemIntoSchedule(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
DeleteTaskClassItem(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
DeleteTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
ApplyBatchIntoSchedule(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
|
||||
}
|
||||
|
||||
type taskClassClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewTaskClassClient(cc grpc.ClientConnInterface) TaskClassClient {
|
||||
return &taskClassClient{cc}
|
||||
}
|
||||
|
||||
func (c *taskClassClient) Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error) {
|
||||
out := new(StatusResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_Ping_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) AddTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_AddTaskClass_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) ListTaskClasses(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_ListTaskClasses_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) GetTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_GetTaskClass_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) UpdateTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_UpdateTaskClass_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) InsertTaskClassItemIntoSchedule(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_InsertTaskClassItemIntoSchedule_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) DeleteTaskClassItem(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_DeleteTaskClassItem_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) DeleteTaskClass(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_DeleteTaskClass_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *taskClassClient) ApplyBatchIntoSchedule(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
|
||||
out := new(JSONResponse)
|
||||
err := c.cc.Invoke(ctx, TaskClass_ApplyBatchIntoSchedule_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
type TaskClassServer interface {
|
||||
Ping(context.Context, *StatusResponse) (*StatusResponse, error)
|
||||
AddTaskClass(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
ListTaskClasses(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
GetTaskClass(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
UpdateTaskClass(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
InsertTaskClassItemIntoSchedule(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
DeleteTaskClassItem(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
DeleteTaskClass(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
ApplyBatchIntoSchedule(context.Context, *JSONRequest) (*JSONResponse, error)
|
||||
}
|
||||
|
||||
type UnimplementedTaskClassServer struct{}
|
||||
|
||||
func (UnimplementedTaskClassServer) Ping(context.Context, *StatusResponse) (*StatusResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) AddTaskClass(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AddTaskClass not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) ListTaskClasses(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListTaskClasses not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) GetTaskClass(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetTaskClass not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) UpdateTaskClass(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateTaskClass not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) InsertTaskClassItemIntoSchedule(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InsertTaskClassItemIntoSchedule not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) DeleteTaskClassItem(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteTaskClassItem not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) DeleteTaskClass(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteTaskClass not implemented")
|
||||
}
|
||||
func (UnimplementedTaskClassServer) ApplyBatchIntoSchedule(context.Context, *JSONRequest) (*JSONResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ApplyBatchIntoSchedule not implemented")
|
||||
}
|
||||
|
||||
func RegisterTaskClassServer(s grpc.ServiceRegistrar, srv TaskClassServer) {
|
||||
s.RegisterService(&TaskClass_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _TaskClass_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StatusResponse)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(TaskClassServer).Ping(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: TaskClass_Ping_FullMethodName}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(TaskClassServer).Ping(ctx, req.(*StatusResponse))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _TaskClass_JSON_Handler(fullMethod string, invoke func(TaskClassServer, context.Context, *JSONRequest) (*JSONResponse, error)) grpc.MethodHandler {
|
||||
return func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(JSONRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return invoke(srv.(TaskClassServer), ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: fullMethod}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return invoke(srv.(TaskClassServer), ctx, req.(*JSONRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
}
|
||||
|
||||
var TaskClass_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "smartflow.taskclass.TaskClass",
|
||||
HandlerType: (*TaskClassServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{MethodName: "Ping", Handler: _TaskClass_Ping_Handler},
|
||||
{MethodName: "AddTaskClass", Handler: _TaskClass_JSON_Handler(TaskClass_AddTaskClass_FullMethodName, TaskClassServer.AddTaskClass)},
|
||||
{MethodName: "ListTaskClasses", Handler: _TaskClass_JSON_Handler(TaskClass_ListTaskClasses_FullMethodName, TaskClassServer.ListTaskClasses)},
|
||||
{MethodName: "GetTaskClass", Handler: _TaskClass_JSON_Handler(TaskClass_GetTaskClass_FullMethodName, TaskClassServer.GetTaskClass)},
|
||||
{MethodName: "UpdateTaskClass", Handler: _TaskClass_JSON_Handler(TaskClass_UpdateTaskClass_FullMethodName, TaskClassServer.UpdateTaskClass)},
|
||||
{MethodName: "InsertTaskClassItemIntoSchedule", Handler: _TaskClass_JSON_Handler(TaskClass_InsertTaskClassItemIntoSchedule_FullMethodName, TaskClassServer.InsertTaskClassItemIntoSchedule)},
|
||||
{MethodName: "DeleteTaskClassItem", Handler: _TaskClass_JSON_Handler(TaskClass_DeleteTaskClassItem_FullMethodName, TaskClassServer.DeleteTaskClassItem)},
|
||||
{MethodName: "DeleteTaskClass", Handler: _TaskClass_JSON_Handler(TaskClass_DeleteTaskClass_FullMethodName, TaskClassServer.DeleteTaskClass)},
|
||||
{MethodName: "ApplyBatchIntoSchedule", Handler: _TaskClass_JSON_Handler(TaskClass_ApplyBatchIntoSchedule_FullMethodName, TaskClassServer.ApplyBatchIntoSchedule)},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "task_class.proto",
|
||||
}
|
||||
60
backend/services/task_class/rpc/server.go
Normal file
60
backend/services/task_class/rpc/server.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/task_class/rpc/pb"
|
||||
taskclasssv "github.com/LoveLosita/smartflow/backend/services/task_class/sv"
|
||||
"github.com/zeromicro/go-zero/core/service"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListenOn = "0.0.0.0:9086"
|
||||
defaultTimeout = 6 * time.Second
|
||||
)
|
||||
|
||||
type ServerOptions struct {
|
||||
ListenOn string
|
||||
Timeout time.Duration
|
||||
Service *taskclasssv.TaskClassService
|
||||
}
|
||||
|
||||
// NewServer 创建 task-class zrpc 服务端。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责 zrpc server 配置与 gRPC handler 注册;
|
||||
// 2. 不创建数据库、Redis 或业务服务,它们由 cmd/task-class 管理;
|
||||
// 3. 返回 listenOn 供进程入口打印启动日志。
|
||||
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
|
||||
if opts.Service == nil {
|
||||
return nil, "", errors.New("task-class service dependency not initialized")
|
||||
}
|
||||
|
||||
listenOn := strings.TrimSpace(opts.ListenOn)
|
||||
if listenOn == "" {
|
||||
listenOn = defaultListenOn
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
server, err := zrpc.NewServer(zrpc.RpcServerConf{
|
||||
ServiceConf: service.ServiceConf{
|
||||
Name: "task-class.rpc",
|
||||
Mode: service.DevMode,
|
||||
},
|
||||
ListenOn: listenOn,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
}, func(grpcServer *grpc.Server) {
|
||||
pb.RegisterTaskClassServer(grpcServer, NewHandler(opts.Service))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return server, listenOn, nil
|
||||
}
|
||||
28
backend/services/task_class/rpc/task_class.proto
Normal file
28
backend/services/task_class/rpc/task_class.proto
Normal file
@@ -0,0 +1,28 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package smartflow.taskclass;
|
||||
|
||||
option go_package = "github.com/LoveLosita/smartflow/backend/services/task_class/rpc/pb";
|
||||
|
||||
service TaskClass {
|
||||
rpc Ping(StatusResponse) returns (StatusResponse);
|
||||
rpc AddTaskClass(JSONRequest) returns (JSONResponse);
|
||||
rpc ListTaskClasses(JSONRequest) returns (JSONResponse);
|
||||
rpc GetTaskClass(JSONRequest) returns (JSONResponse);
|
||||
rpc UpdateTaskClass(JSONRequest) returns (JSONResponse);
|
||||
rpc InsertTaskClassItemIntoSchedule(JSONRequest) returns (JSONResponse);
|
||||
rpc DeleteTaskClassItem(JSONRequest) returns (JSONResponse);
|
||||
rpc DeleteTaskClass(JSONRequest) returns (JSONResponse);
|
||||
rpc ApplyBatchIntoSchedule(JSONRequest) returns (JSONResponse);
|
||||
}
|
||||
|
||||
message JSONRequest {
|
||||
bytes payload_json = 1;
|
||||
}
|
||||
|
||||
message JSONResponse {
|
||||
bytes data_json = 1;
|
||||
}
|
||||
|
||||
message StatusResponse {
|
||||
}
|
||||
553
backend/services/task_class/sv/service.go
Normal file
553
backend/services/task_class/sv/service.go
Normal file
@@ -0,0 +1,553 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
rootdao "github.com/LoveLosita/smartflow/backend/dao"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
taskclassdao "github.com/LoveLosita/smartflow/backend/services/task_class/dao"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TaskClassService struct {
|
||||
// 这里可以添加数据库连接或其他依赖
|
||||
taskClassRepo *taskclassdao.TaskClassDAO
|
||||
cacheRepo *rootdao.CacheDAO
|
||||
scheduleRepo *rootdao.ScheduleDAO
|
||||
repoManager *rootdao.RepoManager // 统一管理多个 DAO 的事务
|
||||
}
|
||||
|
||||
func NewTaskClassService(taskClassRepo *taskclassdao.TaskClassDAO, cacheRepo *rootdao.CacheDAO, scheduleRepo *rootdao.ScheduleDAO, manager *rootdao.RepoManager) *TaskClassService {
|
||||
return &TaskClassService{
|
||||
taskClassRepo: taskClassRepo,
|
||||
cacheRepo: cacheRepo,
|
||||
scheduleRepo: scheduleRepo,
|
||||
repoManager: manager,
|
||||
}
|
||||
}
|
||||
|
||||
// AddOrUpdateTaskClass 为指定用户添加任务类
|
||||
func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model.UserAddTaskClassRequest, userID int, method int, targetTaskClassID int) error {
|
||||
//1.先校验参数
|
||||
if req.Mode == "auto" {
|
||||
if req.StartDate == "" || req.EndDate == "" {
|
||||
return respond.MissingParamForAutoScheduling
|
||||
}
|
||||
st, err := time.Parse("2006-01-02", req.StartDate)
|
||||
if err != nil {
|
||||
return respond.WrongParamType
|
||||
}
|
||||
ed, err := time.Parse("2006-01-02", req.EndDate)
|
||||
if err != nil {
|
||||
return respond.WrongParamType
|
||||
}
|
||||
if st.After(ed) {
|
||||
return respond.InvalidDateRange
|
||||
}
|
||||
}
|
||||
if req.Mode == "" || req.Name == "" || len(req.Items) == 0 {
|
||||
return respond.MissingParam
|
||||
}
|
||||
// 1. excluded_slots 属于“半天块索引”,每个索引映射 2 节(1->1-2,...,6->11-12);
|
||||
// 2. 若允许 7~12,会在粗排网格展开时产生越界节次,触发运行时 panic;
|
||||
// 3. 这里统一在写入入口拦截,避免脏数据落库后污染后续排程链路。
|
||||
for _, slot := range req.Config.ExcludedSlots {
|
||||
if slot < 1 || slot > 6 {
|
||||
return respond.WrongParamType
|
||||
}
|
||||
}
|
||||
// 1. excluded_days_of_week 表示“整天不可排”的硬约束,粗排时会直接整天屏蔽;
|
||||
// 2. 只允许 1~7,对应周一到周日;
|
||||
// 3. 若写入非法值,会导致粗排过滤口径和前端展示口径不一致,因此入口直接拦截。
|
||||
for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek {
|
||||
if dayOfWeek < 1 || dayOfWeek > 7 {
|
||||
return respond.WrongParamType
|
||||
}
|
||||
}
|
||||
//2.写数据库(事务内)
|
||||
if err := sv.taskClassRepo.Transaction(func(txDAO *taskclassdao.TaskClassDAO) error {
|
||||
taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if method == 1 { // 更新操作
|
||||
taskClass.ID = targetTaskClassID
|
||||
}
|
||||
|
||||
taskClassID, err := txDAO.AddOrUpdateTaskClass(userID, taskClass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
items[i].CategoryID = &taskClassID
|
||||
}
|
||||
if err := txDAO.AddOrUpdateTaskClassItems(userID, items); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sv *TaskClassService) GetUserTaskClassInfos(ctx context.Context, userID int) (*model.UserGetTaskClassesResponse, error) {
|
||||
//1.先查询redis
|
||||
list, err := sv.cacheRepo.GetTaskClassList(ctx, userID)
|
||||
if err == nil {
|
||||
//命中缓存
|
||||
return list, nil
|
||||
} else if !errors.Is(err, redis.Nil) { //不是缓存未命中错误,说明redis可能炸了,照常放行
|
||||
log.Println("redis获取任务分类列表失败:", err)
|
||||
}
|
||||
//2.缓存未命中,查询数据库
|
||||
taskClasses, err := sv.taskClassRepo.GetUserTaskClasses(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := conv.TaskClassModelToResponse(taskClasses)
|
||||
//3.写入缓存
|
||||
err = sv.cacheRepo.AddTaskClassList(ctx, userID, resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (sv *TaskClassService) GetUserCompleteTaskClass(ctx context.Context, userID int, taskClassID int) (*model.UserAddTaskClassRequest, error) {
|
||||
//1.查询数据库
|
||||
taskClass, err := sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//2.转换为响应结构体
|
||||
resp, err := conv.ProcessUserGetCompleteTaskClassRequest(taskClass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, req *model.UserInsertTaskClassItemToScheduleRequest, userID int, taskID int) error {
|
||||
//1.先验证任务块归属
|
||||
taskClassID, err := sv.taskClassRepo.GetTaskClassIDByTaskItemID(ctx, taskID) //通过任务块ID获取所属任务类ID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownerID, err := sv.taskClassRepo.GetTaskClassUserIDByID(ctx, taskClassID) //通过任务类ID获取所属用户ID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerID != userID {
|
||||
return respond.TaskClassItemNotBelongToUser
|
||||
}
|
||||
//2.再检查任务块本身是否已经被安排
|
||||
result, err := sv.taskClassRepo.IfTaskClassItemArranged(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result {
|
||||
return respond.TaskClassItemAlreadyArranged
|
||||
}
|
||||
//3.取出任务块信息
|
||||
taskItem, err := sv.taskClassRepo.GetTaskClassItemByID(ctx, taskID) //通过任务块ID获取任务块信息
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//更新TaskClassItem的embedded_time字段
|
||||
taskItem.EmbeddedTime = &model.TargetTime{
|
||||
DayOfWeek: req.DayOfWeek,
|
||||
Week: req.Week,
|
||||
SectionFrom: req.StartSection,
|
||||
SectionTo: req.EndSection,
|
||||
}
|
||||
//3.判断是否嵌入课程
|
||||
if req.EmbedCourseEventID != 0 {
|
||||
//先检查看课程是否存在、是否归属该用户以及是否已经被嵌入了其他任务块
|
||||
courseOwnerID, err := sv.scheduleRepo.GetCourseUserIDByID(ctx, req.EmbedCourseEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if courseOwnerID != userID {
|
||||
return respond.CourseNotBelongToUser
|
||||
}
|
||||
//再检查用户给的时间是否和课程的时间匹配(目前逻辑是给的区间必须完全匹配)
|
||||
match, err := sv.scheduleRepo.IsCourseTimeMatch(ctx, req.EmbedCourseEventID, req.Week, req.DayOfWeek, req.StartSection, req.EndSection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !match {
|
||||
return respond.CourseTimeNotMatch
|
||||
}
|
||||
//查询对应时段的课程是否已被其他任务块嵌入了(目前业务限制:一个课程只能被一个任务块嵌入,但是目前设计是支持多个任务块嵌入一节课的,只要放得下)
|
||||
isEmbedded, err := sv.scheduleRepo.IsCourseEmbeddedByOtherTaskBlock(ctx, req.EmbedCourseEventID, req.StartSection, req.EndSection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isEmbedded {
|
||||
return respond.CourseAlreadyEmbeddedByOtherTaskBlock
|
||||
}
|
||||
//嵌入课程,直接更新日程表对应时段的 embedded_task_id 字段
|
||||
err = sv.scheduleRepo.EmbedTaskIntoSchedule(req.StartSection, req.EndSection, req.DayOfWeek, req.Week, userID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//更新任务块的 embedded_time 字段
|
||||
err = sv.taskClassRepo.UpdateTaskClassItemEmbeddedTime(ctx, taskID, taskItem.EmbeddedTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
//4.否则构造Schedule模型
|
||||
sections := make([]int, 0, req.EndSection-req.StartSection+1)
|
||||
schedules, scheduleEvent, err := conv.UserInsertTaskItemRequestToModel(req, taskItem, nil, userID, req.StartSection, req.EndSection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//将节次区间转换为节次切片,方便后续检查冲突
|
||||
for section := req.StartSection; section <= req.EndSection; section++ {
|
||||
sections = append(sections, section)
|
||||
}
|
||||
//4.1 统一检查冲突(避免逐条查库)
|
||||
conflict, err := sv.scheduleRepo.HasUserScheduleConflict(ctx, userID, req.Week, req.DayOfWeek, sections)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if conflict {
|
||||
return respond.ScheduleConflict
|
||||
}
|
||||
// 5. 写入数据库(通过 RepoManager 统一管理事务)
|
||||
// 这里的 sv.daoManager 是你在初始化 Service 时注入的全局 RepoManager 实例
|
||||
if err := sv.repoManager.Transaction(ctx, func(txM *rootdao.RepoManager) error {
|
||||
// 5.1 使用事务中的 ScheduleRepo 插入 Event
|
||||
// 💡 这里的 txM.Schedule 已经注入了事务句柄
|
||||
//此处要将req中的起始section以及第几周、星期几转换成绝对时间,存入scheduleEvent的StartTime和EndTime字段中,方便后续查询和冲突检查
|
||||
st, ed, err := conv.RelativeTimeToRealTime(req.Week, req.DayOfWeek, req.StartSection, req.EndSection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scheduleEvent.StartTime = st
|
||||
scheduleEvent.EndTime = ed
|
||||
eventID, err := txM.Schedule.AddScheduleEvent(scheduleEvent)
|
||||
if err != nil {
|
||||
return err // 触发回滚
|
||||
}
|
||||
// 5.2 关联 ID(纯内存操作,无需 tx)
|
||||
for i := range schedules {
|
||||
schedules[i].EventID = eventID
|
||||
}
|
||||
// 5.3 使用事务中的 ScheduleRepo 批量插入原子槽位
|
||||
// 💡 如果这里因为外键或唯一索引报错,5.1 的 Event 也会被撤回
|
||||
if _, err = txM.Schedule.AddSchedules(schedules); err != nil {
|
||||
return err // 触发回滚
|
||||
}
|
||||
// 5.4 使用事务中的 TaskRepo 更新任务状态
|
||||
// 💡 这里的 txM.Task 取代了你原来的 txDAO
|
||||
if err := txM.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, taskID, taskItem.EmbeddedTime); err != nil {
|
||||
return err // 触发回滚
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
// 这里处理最终的错误返回,比如 respond.Error
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sv *TaskClassService) DeleteTaskClassItem(ctx context.Context, userID int, taskItemID int) error {
|
||||
//1.先验证任务块归属
|
||||
taskClassID, err := sv.taskClassRepo.GetTaskClassIDByTaskItemID(ctx, taskItemID) //通过任务块ID获取所属任务类ID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownerID, err := sv.taskClassRepo.GetTaskClassUserIDByID(ctx, taskClassID) //通过任务类ID获取所属用户ID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerID != userID {
|
||||
return respond.TaskClassItemNotBelongToUser
|
||||
}
|
||||
//2.如果该任务块已经被安排了,先解除安排,再删除任务块(事务)
|
||||
if err := sv.repoManager.Transaction(ctx, func(txM *rootdao.RepoManager) error {
|
||||
//2.1.先检查该任务块是否已经被安排了
|
||||
arranged, err := txM.TaskClass.IfTaskClassItemArranged(ctx, taskItemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if arranged {
|
||||
//2.2.如果已经被安排了,先解除安排
|
||||
//先扫schedules找到该task_item_id并删除
|
||||
_, txErr := txM.Schedule.FindEmbeddedTaskIDAndDeleteIt(ctx, taskItemID)
|
||||
//2.3.再将task_items表的embedded_time字段设置为null
|
||||
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskItemID)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
//再删除schedule_event表中对应的事件
|
||||
txErr = txM.Schedule.DeleteScheduleEventByTaskItemID(ctx, taskItemID)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
}
|
||||
//2.4.最后删除任务块
|
||||
err = txM.TaskClass.DeleteTaskClassItemByID(ctx, taskItemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sv *TaskClassService) DeleteTaskClass(ctx context.Context, userID int, taskClassID int) error {
|
||||
//1.先验证任务类归属
|
||||
ownerID, err := sv.taskClassRepo.GetTaskClassUserIDByID(ctx, taskClassID) //通过任务类ID获取所属用户ID
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return respond.WrongTaskClassID
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ownerID != userID {
|
||||
return respond.TaskClassNotBelongToUser
|
||||
}
|
||||
//2.删除任务类(事务)
|
||||
err = sv.taskClassRepo.DeleteTaskClassByID(ctx, taskClassID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCompleteTaskClassByID 获取任务类完整详情(含关联的 TaskClassItem 列表)。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1) 直接委托 DAO 层查询,不做额外业务逻辑;
|
||||
// 2) 主要供 Agent 排程链路使用,获取 Items 用于 materialize 节点映射。
|
||||
func (sv *TaskClassService) GetCompleteTaskClassByID(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) {
|
||||
return sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)
|
||||
}
|
||||
|
||||
func (sv *TaskClassService) BatchApplyPlans(ctx context.Context, taskClassID int, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error {
|
||||
//1.通过任务类id获取任务类详情
|
||||
taskClass, err := sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return respond.WrongTaskClassID
|
||||
}
|
||||
return err
|
||||
}
|
||||
//2.校验任务类的参数是否合法
|
||||
if taskClass == nil {
|
||||
return respond.WrongTaskClassID
|
||||
}
|
||||
if *taskClass.Mode != "auto" {
|
||||
return respond.TaskClassModeNotAuto
|
||||
}
|
||||
//3.获取任务类安排的时间范围内的全部周数信息(左右边界不足一周的情况也要算作一周),用于下方冲突检查
|
||||
startWeekTime := conv.CalculateFirstDayOfWeek(*taskClass.StartDate)
|
||||
endWeekTime := conv.CalculateLastDayOfWeek(*taskClass.EndDate)
|
||||
schedules, err := sv.scheduleRepo.GetUserSchedulesByTimeRange(ctx, userID, startWeekTime, endWeekTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
startWeek, _, err := conv.RealDateToRelativeDate(startWeekTime.Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endWeek, _, err := conv.RealDateToRelativeDate(endWeekTime.Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//4.统一检查冲突(避免逐条查库)
|
||||
//先将日程放入一个map中,key是"周-星期-节次",value是课程信息,方便后续检查冲突
|
||||
courseMap := make(map[string]model.Schedule)
|
||||
for _, schedule := range schedules {
|
||||
key := fmt.Sprintf("%d-%d-%d", schedule.Week, schedule.DayOfWeek, schedule.Section)
|
||||
courseMap[key] = schedule
|
||||
}
|
||||
//再遍历每个任务块的安排时间,检查是否和课程冲突(目前逻辑是只要有一个时段冲突就算冲突,后续可以优化为统计冲突的时段数量,或者提供具体的冲突时段信息)
|
||||
for _, plan := range plans.Items {
|
||||
if plan.Week < startWeek || plan.Week > endWeek {
|
||||
return respond.TaskClassItemTryingToInsertOutOfTimeRange
|
||||
}
|
||||
for section := plan.StartSection; section <= plan.EndSection; section++ {
|
||||
key := fmt.Sprintf("%d-%d-%d", plan.Week, plan.DayOfWeek, section)
|
||||
// 如果课程存在,并且满足以下任一条件则认为冲突:
|
||||
// 1. 课程时段已经被其他任务块嵌入了(不允许多个任务块嵌入同一课程)
|
||||
// 2. 当前时段的课的EventID与用户计划中指定的EmbedCourseEventID不匹配(说明用户计划要嵌入的课程和当前时段的课不是同一节)
|
||||
// 3. 用户计划中没有指定EmbedCourseEventID(即EmbedCourseEventID为0),但当前时段有课(不允许在有课的时段安排任务块)
|
||||
// 4. 当前时段的课不允许被嵌入(即使用户计划中指定了EmbedCourseEventID,但如果课程本身不允许被嵌入了,也算冲突)
|
||||
if course, exists := courseMap[key]; exists && ((plan.EmbedCourseEventID != 0 && course.EmbeddedTask != nil) ||
|
||||
(plan.EmbedCourseEventID != course.EventID) || plan.EmbedCourseEventID == 0 || !course.Event.CanBeEmbedded) {
|
||||
return respond.ScheduleConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
//5.分流批量写入数据库(通过 RepoManager 统一管理事务)
|
||||
//先分流
|
||||
toEmbed := make([]model.SingleTaskClassItem, 0) //需要嵌入课程的任务块
|
||||
toNormal := make([]model.SingleTaskClassItem, 0) //需要新建日程的任务块
|
||||
for _, item := range plans.Items {
|
||||
if item.EmbedCourseEventID != 0 {
|
||||
toEmbed = append(toEmbed, item)
|
||||
} else {
|
||||
toNormal = append(toNormal, item)
|
||||
}
|
||||
}
|
||||
//再开事务批量写库
|
||||
if err := sv.repoManager.Transaction(ctx, func(txM *rootdao.RepoManager) error {
|
||||
//5.1 先处理需要嵌入课程的任务块
|
||||
//先提取出需要嵌入的课程ID和TaskItemID列表
|
||||
courseIDs := make([]int, 0, len(toEmbed))
|
||||
for _, item := range toEmbed {
|
||||
courseIDs = append(courseIDs, item.EmbedCourseEventID)
|
||||
}
|
||||
itemIDs := make([]int, 0, len(toEmbed))
|
||||
for _, item := range toEmbed {
|
||||
itemIDs = append(itemIDs, item.TaskItemID)
|
||||
}
|
||||
//检查任务块本身是否已经被安排
|
||||
result, err := sv.taskClassRepo.BatchCheckIfTaskClassItemsArranged(ctx, itemIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result {
|
||||
return respond.TaskClassItemAlreadyArranged
|
||||
}
|
||||
//验证一下plans中的taskItemID确实都属于这个用户和这个任务类(避免用户恶意构造请求把别的用户的任务块或者不属于任何任务类的任务块也安排了)
|
||||
//同时也能检查是否重复
|
||||
result, err = sv.taskClassRepo.ValidateTaskItemIDsBelongToTaskClass(ctx, taskClassID, itemIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !result {
|
||||
return respond.TaskClassItemNotBelongToTaskClass
|
||||
}
|
||||
//批量更新日程表中对应课程的embedded_task_id字段(目前业务限制:一个课程只能被一个任务块嵌入了,所以直接批量更新,不用担心覆盖问题)
|
||||
err = txM.Schedule.BatchEmbedTaskIntoSchedule(ctx, courseIDs, itemIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//批量更新任务块的embedded_time字段
|
||||
targetTimes := make([]*model.TargetTime, 0, len(toEmbed))
|
||||
for _, item := range toEmbed {
|
||||
targetTimes = append(targetTimes, &model.TargetTime{
|
||||
DayOfWeek: item.DayOfWeek,
|
||||
Week: item.Week,
|
||||
SectionFrom: item.StartSection,
|
||||
SectionTo: item.EndSection,
|
||||
})
|
||||
}
|
||||
err = txM.TaskClass.BatchUpdateTaskClassItemEmbeddedTime(ctx, itemIDs, targetTimes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//5.2 再处理需要新建日程的任务块
|
||||
//先提取出需要新建日程的任务块ID列表
|
||||
normalItemIDs := make([]int, 0, len(toNormal))
|
||||
for _, item := range toNormal {
|
||||
normalItemIDs = append(normalItemIDs, item.TaskItemID)
|
||||
}
|
||||
//验证一下plans中的taskItemID确实都属于这个任务类(避免用户恶意构造请求把别的用户的任务块或者不属于任何任务类的任务块也安排了)
|
||||
result, err = sv.taskClassRepo.ValidateTaskItemIDsBelongToTaskClass(ctx, taskClassID, normalItemIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !result {
|
||||
return respond.TaskClassItemNotBelongToTaskClass
|
||||
}
|
||||
//批量提取TaskItems
|
||||
taskItems, err := txM.TaskClass.GetTaskClassItemsByIDs(ctx, normalItemIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(taskItems) != len(normalItemIDs) {
|
||||
log.Printf("警告:批量提取任务块时,返回的任务块数量与请求中的任务块ID数量不匹配,可能存在数据问题。请求ID数量:%d,返回任务块数量:%d", len(normalItemIDs), len(taskItems))
|
||||
return respond.InternalError(errors.New("返回的任务块数量与请求中的任务块ID数量不匹配,可能存在数据问题"))
|
||||
}
|
||||
//将toNormal按照TaskItemID升序排序,将taskItems也按照ID升序排序,保证一一对应关系(上面已经检查过重复)
|
||||
//如果请求中的任务块ID有重复,这里就无法保证一一对应关系了,后续可以考虑在请求层面加一个校验,拒绝包含重复任务块ID的请求
|
||||
sort.SliceStable(toNormal, func(i, j int) bool {
|
||||
return toNormal[i].TaskItemID < toNormal[j].TaskItemID
|
||||
})
|
||||
sort.SliceStable(taskItems, func(i, j int) bool {
|
||||
return taskItems[i].ID < taskItems[j].ID
|
||||
})
|
||||
//开始构建event和schedules
|
||||
finalSchedules := make([]model.Schedule, 0) //最终要插入数据库的Schedule切片
|
||||
finalScheduleEvents := make([]model.ScheduleEvent, 0) //最终要插入数据库的ScheduleEvent切片
|
||||
pos := make([]int, 0) //记录每个任务块对应的Schedule在finalSchedules中的位置,方便后续批量插入数据库后回填EventID
|
||||
for i := 0; i < len(toNormal); i++ {
|
||||
item := toNormal[i]
|
||||
taskItem := taskItems[i]
|
||||
if item.StartSection < 1 || item.EndSection > 12 || item.StartSection > item.EndSection {
|
||||
return respond.InvalidSectionRange
|
||||
}
|
||||
schedules, scheduleEvent, err := conv.UserInsertTaskItemRequestToModel(&model.UserInsertTaskClassItemToScheduleRequest{
|
||||
Week: item.Week,
|
||||
DayOfWeek: item.DayOfWeek,
|
||||
StartSection: item.StartSection,
|
||||
EndSection: item.EndSection,
|
||||
EmbedCourseEventID: 0, //不嵌入课程
|
||||
}, &taskItem, nil, userID, item.StartSection, item.EndSection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finalScheduleEvents = append(finalScheduleEvents, *scheduleEvent)
|
||||
for range schedules {
|
||||
pos = append(pos, len(finalScheduleEvents)-1)
|
||||
}
|
||||
finalSchedules = append(finalSchedules, schedules...)
|
||||
}
|
||||
//最后批量插入数据库
|
||||
//先插入ScheduleEvent表,获取生成的EventID,再批量插入Schedule表,最后批量更新TaskClassItem的embedded_time字段
|
||||
ids, err := txM.Schedule.InsertScheduleEvents(ctx, finalScheduleEvents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 将生成的 ScheduleEvent ID 赋值给对应的 Schedule 的 EventID 字段
|
||||
for i := range finalSchedules {
|
||||
finalSchedules[i].EventID = ids[pos[i]]
|
||||
}
|
||||
if _, err = txM.Schedule.AddSchedules(finalSchedules); err != nil {
|
||||
return err
|
||||
}
|
||||
//批量更新任务块的embedded_time字段
|
||||
targetTimes = make([]*model.TargetTime, 0, len(toEmbed))
|
||||
for _, item := range toNormal {
|
||||
targetTimes = append(targetTimes, &model.TargetTime{
|
||||
DayOfWeek: item.DayOfWeek,
|
||||
Week: item.Week,
|
||||
SectionFrom: item.StartSection,
|
||||
SectionTo: item.EndSection,
|
||||
})
|
||||
}
|
||||
//提取出所有需要更新的任务块ID
|
||||
itemIDs = make([]int, 0, len(toNormal))
|
||||
for _, item := range toNormal {
|
||||
itemIDs = append(itemIDs, item.TaskItemID)
|
||||
}
|
||||
err = txM.TaskClass.BatchUpdateTaskClassItemEmbeddedTime(ctx, itemIDs, targetTimes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user