diff --git a/api/handler/api/course_service.go b/api/handler/api/course_service.go index e40f7c76b..5790cf249 100644 --- a/api/handler/api/course_service.go +++ b/api/handler/api/course_service.go @@ -57,7 +57,7 @@ func GetCourseList(ctx context.Context, c *app.RequestContext) { } resp := new(api.CourseListResponse) - resp.Data = pack.BuildCourseList(res) + resp.Data = pack.BuildCourseList(res.Data) pack.RespList(c, resp.Data) } @@ -200,3 +200,83 @@ func UpdateAdjustCourse(ctx context.Context, c *app.RequestContext) { pack.RespSuccess(c) } + +// UpsertCustomCourse 新增或更新自定义课程 +// @router /api/v1/course/custom [POST] +func UpsertCustomCourse(ctx context.Context, c *app.RequestContext) { + var req api.UpsertCustomCourseRequest + var err error + err = c.BindAndValidate(&req) + if err != nil { + pack.RespError(c, errno.ParamError.WithError(err)) + return + } + if req.Course == nil { + pack.RespError(c, errno.ParamError) + return + } + + res, err := rpc.UpsertCustomCourseRPC(ctx, &course.UpsertCustomCourseRequest{ + Term: req.Term, + Course: pack.BuildCustomCourseItemForRPC(req.Course), + }) + if err != nil { + pack.RespError(c, err) + return + } + + resp := new(api.UpsertCustomCourseResponse) + resp.CourseID = res.CourseId + pack.RespList(c, resp) +} + +// DeleteCustomCourse 删除自定义课程 +// @router /api/v1/course/custom [DELETE] +func DeleteCustomCourse(ctx context.Context, c *app.RequestContext) { + var req api.DeleteCustomCourseRequest + var err error + err = c.BindAndValidate(&req) + if err != nil { + pack.RespError(c, errno.ParamError.WithError(err)) + return + } + + err = rpc.DeleteCustomCourseRPC(ctx, &course.DeleteCustomCourseRequest{ + Term: req.Term, + CourseId: req.CourseID, + }) + if err != nil { + pack.RespError(c, err) + return + } + + pack.RespSuccess(c) +} + +// GetCourseListV2 . +// @router /api/v2/course/list [GET] +func GetCourseListV2(ctx context.Context, c *app.RequestContext) { + var req api.CourseListV2Request + var err error + + err = c.BindAndValidate(&req) + if err != nil { + pack.RespError(c, errno.ParamError.WithError(err)) + return + } + + // 复用 v1 RPC:返回值已含 CustomCourses(由 kitex handler getCustomCourses 填充) + res, err := rpc.GetCourseListRPC(ctx, &course.CourseListRequest{ + Term: req.Term, + IsRefresh: req.IsRefresh, + }) + if err != nil { + pack.RespError(c, err) + return + } + + resp := new(api.CourseListV2Response) + resp.Data = pack.BuildCourseList(res.Data) + resp.CustomCourses = pack.BuildCustomCourseItemList(res.CustomCourses) + pack.RespList(c, resp) +} diff --git a/api/handler/api/course_service_test.go b/api/handler/api/course_service_test.go index 84c120247..004657261 100644 --- a/api/handler/api/course_service_test.go +++ b/api/handler/api/course_service_test.go @@ -55,7 +55,7 @@ func TestGetCourseList(t *testing.T) { type testCase struct { name string url string - mockResp []*model.Course + mockResp *course.CourseListResponse mockErr error expectContains string } @@ -64,7 +64,7 @@ func TestGetCourseList(t *testing.T) { { name: "success", url: "/api/v1/jwch/course/list?term=202401", - mockResp: []*model.Course{}, + mockResp: &course.CourseListResponse{Data: []*model.Course{}}, expectContains: `{"code":"10000","message":"ok","data":[]}`, }, { @@ -86,7 +86,7 @@ func TestGetCourseList(t *testing.T) { defer mockey.UnPatchAll() for _, tc := range testCases { mockey.PatchConvey(tc.name, t, func() { - mockey.Mock(rpc.GetCourseListRPC).To(func(ctx context.Context, req *course.CourseListRequest) ([]*model.Course, error) { + mockey.Mock(rpc.GetCourseListRPC).To(func(ctx context.Context, req *course.CourseListRequest) (*course.CourseListResponse, error) { return tc.mockResp, tc.mockErr }).Build() @@ -97,6 +97,216 @@ func TestGetCourseList(t *testing.T) { } } +func TestGetCourseListV2(t *testing.T) { + type testCase struct { + name string + url string + mockResp *course.CourseListResponse + mockErr error + expectContains string + expectAbsence string + } + + testCases := []testCase{ + { + name: "success", + url: "/api/v2/course/list?term=202401", + mockResp: &course.CourseListResponse{Data: []*model.Course{}}, + expectContains: `{"code":"10000","message":"ok","data":{"base":null,"data":[],"custom_courses":[]}}`, + }, + { + name: "success", + url: "/api/v2/course/list?term=202401", + mockResp: &course.CourseListResponse{ + Data: []*model.Course{}, + CustomCourses: []*course.CustomCourseItem{ + {Name: "x", Location: "y", StartClass: 1, EndClass: 2, StartWeek: 1, EndWeek: 2, Weekday: 1}, + }, + }, + expectContains: `"custom_courses":[{"name":"x"`, + }, + { + name: "rpc error", + url: "/api/v2/course/list?term=202401", + mockErr: errno.InternalServiceError, + expectContains: `{"code":"50001","message":"内部服务错误"}`, + }, + { + name: "bind error", + url: "/api/v2/course/list", + expectContains: `{"code":"20001","message":"参数错误,`, + }, + } + + router := route.NewEngine(&config.Options{}) + router.GET("/api/v2/course/list", GetCourseListV2) + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockey.Mock(rpc.GetCourseListRPC).To(func(ctx context.Context, req *course.CourseListRequest) (*course.CourseListResponse, error) { + return tc.mockResp, tc.mockErr + }).Build() + + res := ut.PerformRequest(router, consts.MethodGet, tc.url, nil) + assert.Equal(t, consts.StatusOK, res.Result().StatusCode()) + body := string(res.Result().Body()) + assert.Contains(t, body, tc.expectContains) + if tc.expectAbsence != "" { + assert.NotContains(t, body, tc.expectAbsence) + } + }) + } +} + +func TestDeleteCustomCourse(t *testing.T) { + type testCase struct { + name string + url string + body string + mockErr error + expectContains string + } + + testCases := []testCase{ + { + name: "success", + url: "/api/v1/course/custom", + body: `{"term":"202401","course_id":"114514"}`, + expectContains: `{"code":"10000","message":"ok"}`, + }, + { + name: "rpc error", + url: "/api/v1/course/custom", + body: `{"term":"202401","course_id":"114514"}`, + mockErr: errno.InternalServiceError, + expectContains: `{"code":"50001","message":"内部服务错误"}`, + }, + { + name: "bind error", + url: "/api/v1/course/custom", + body: `{"course_id":"114514"}`, + expectContains: `{"code":"20001","message":"参数错误,`, + }, + } + + router := route.NewEngine(&config.Options{}) + router.DELETE("/api/v1/course/custom", DeleteCustomCourse) + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockey.Mock(rpc.DeleteCustomCourseRPC).To(func(ctx context.Context, req *course.DeleteCustomCourseRequest) error { + return tc.mockErr + }).Build() + + body := &ut.Body{ + Body: bytes.NewBufferString(tc.body), + Len: len(tc.body), + } + res := ut.PerformRequest(router, consts.MethodDelete, tc.url, body, ut.Header{ + Key: "Content-Type", + Value: "application/json", + }) + assert.Equal(t, consts.StatusOK, res.Result().StatusCode()) + assert.Contains(t, string(res.Result().Body()), tc.expectContains) + }) + } +} + +func TestUpsertCustomCourse(t *testing.T) { + type testCase struct { + name string + url string + body string + mockResp *course.UpsertCustomCourseResponse + mockErr error + expectContains string + } + + testCases := []testCase{ + { + name: "success", + url: "/api/v1/course/custom", + body: `{"term":"202401","course":` + + `{"name":"x","location":"y","start_class":1,"end_class":2,` + + `"start_week":1,"end_week":2,"weekday":1,"single":false,"double_":false}}`, + mockResp: &course.UpsertCustomCourseResponse{}, + expectContains: `{"code":"10000","message":"ok","data":`, + }, + { + name: "rpc error", + url: "/api/v1/course/custom", + body: `{"term":"202401","course":` + + `{"name":"x","location":"y","start_class":1,"end_class":2,` + + `"start_week":1,"end_week":2,"weekday":1,"single":false,"double_":false}}`, + mockErr: errno.InternalServiceError, + expectContains: `{"code":"50001","message":"内部服务错误"}`, + }, + { + name: "missing course rejected at bind", + url: "/api/v1/course/custom", + body: `{"term":"202401"}`, + expectContains: `{"code":"20001","message":"参数错误,`, + }, + { + name: "bind error", + url: "/api/v1/course/custom", + body: `{"term":"202401","course":{"name":"x"}}`, + expectContains: `{"code":"20001","message":"参数错误,`, + }, + } + + router := route.NewEngine(&config.Options{}) + router.POST("/api/v1/course/custom", UpsertCustomCourse) + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockey.Mock(rpc.UpsertCustomCourseRPC).To(func(ctx context.Context, req *course.UpsertCustomCourseRequest) (*course.UpsertCustomCourseResponse, error) { + return tc.mockResp, tc.mockErr + }).Build() + + body := &ut.Body{ + Body: bytes.NewBufferString(tc.body), + Len: len(tc.body), + } + res := ut.PerformRequest(router, consts.MethodPost, tc.url, body, ut.Header{ + Key: "Content-Type", + Value: "application/json", + }) + assert.Equal(t, consts.StatusOK, res.Result().StatusCode()) + assert.Contains(t, string(res.Result().Body()), tc.expectContains) + }) + } +} + +func TestUpsertCustomCourseCourseNil(t *testing.T) { + router := route.NewEngine(&config.Options{}) + router.POST("/api/v1/course/custom", UpsertCustomCourse) + + defer mockey.UnPatchAll() + mockey.PatchConvey("course nil reaches nil check", t, func() { + // 绕过 hertz 的 required 校验,使 req.Course 保持 nil,覆盖 handler 中的防御性检查 + mockey.Mock((*app.RequestContext).BindAndValidate).To( + func(c *app.RequestContext, req interface{}) error { + return nil + }, + ).Build() + + body := &ut.Body{ + Body: bytes.NewBufferString(`{"term":"202401"}`), + Len: len(`{"term":"202401"}`), + } + res := ut.PerformRequest(router, consts.MethodPost, "/api/v1/course/custom", body, ut.Header{ + Key: "Content-Type", + Value: "application/json", + }) + assert.Equal(t, consts.StatusOK, res.Result().StatusCode()) + assert.Contains(t, string(res.Result().Body()), `{"code":"20001","message":"参数错误"}`) + }) +} + func TestGetTermList(t *testing.T) { type testCase struct { name string diff --git a/api/mcp/course.go b/api/mcp/course.go index 35ad0cfe3..88ed4394c 100644 --- a/api/mcp/course.go +++ b/api/mcp/course.go @@ -118,7 +118,7 @@ func handleGetCourse(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal // 包装成JSON,JSON数组直接返回时不合法的 resp := map[string]any{ "term": term, - "courses": courseList, + "courses": courseList.Data, } return mcp.NewToolResultJSON(resp) diff --git a/api/model/api/api.go b/api/model/api/api.go index 4ad1834d4..47441e0de 100644 --- a/api/model/api/api.go +++ b/api/model/api/api.go @@ -835,6 +835,124 @@ func (p *ReorderFriendListResponse) String() string { // # ---------------------------------------------------------------------------- // # course 课表 // # ---------------------------------------------------------------------------- +type CustomCourseItem struct { + ID *string `thrift:"id,1,optional" form:"id" json:"id,omitempty" query:"id"` + Name string `thrift:"name,2,required" form:"name,required" json:"name,required" query:"name,required"` + Teacher *string `thrift:"teacher,3,optional" form:"teacher" json:"teacher,omitempty" query:"teacher"` + Location string `thrift:"location,4,required" form:"location,required" json:"location,required" query:"location,required"` + StartClass int32 `thrift:"start_class,5,required" form:"start_class,required" json:"start_class,required" query:"start_class,required"` + EndClass int32 `thrift:"end_class,6,required" form:"end_class,required" json:"end_class,required" query:"end_class,required"` + StartWeek int32 `thrift:"start_week,7,required" form:"start_week,required" json:"start_week,required" query:"start_week,required"` + EndWeek int32 `thrift:"end_week,8,required" form:"end_week,required" json:"end_week,required" query:"end_week,required"` + Weekday int32 `thrift:"weekday,9,required" form:"weekday,required" json:"weekday,required" query:"weekday,required"` + Single bool `thrift:"single,10,required" form:"single,required" json:"single,required" query:"single,required"` + Double_ bool `thrift:"double_,11,required" form:"double_,required" json:"double_,required" query:"double_,required"` + Color *string `thrift:"color,12,optional" form:"color" json:"color,omitempty" query:"color"` + Remark *string `thrift:"remark,13,optional" form:"remark" json:"remark,omitempty" query:"remark"` +} + +func NewCustomCourseItem() *CustomCourseItem { + return &CustomCourseItem{} +} + +func (p *CustomCourseItem) InitDefault() { +} + +var CustomCourseItem_ID_DEFAULT string + +func (p *CustomCourseItem) GetID() (v string) { + if !p.IsSetID() { + return CustomCourseItem_ID_DEFAULT + } + return *p.ID +} + +func (p *CustomCourseItem) GetName() (v string) { + return p.Name +} + +var CustomCourseItem_Teacher_DEFAULT string + +func (p *CustomCourseItem) GetTeacher() (v string) { + if !p.IsSetTeacher() { + return CustomCourseItem_Teacher_DEFAULT + } + return *p.Teacher +} + +func (p *CustomCourseItem) GetLocation() (v string) { + return p.Location +} + +func (p *CustomCourseItem) GetStartClass() (v int32) { + return p.StartClass +} + +func (p *CustomCourseItem) GetEndClass() (v int32) { + return p.EndClass +} + +func (p *CustomCourseItem) GetStartWeek() (v int32) { + return p.StartWeek +} + +func (p *CustomCourseItem) GetEndWeek() (v int32) { + return p.EndWeek +} + +func (p *CustomCourseItem) GetWeekday() (v int32) { + return p.Weekday +} + +func (p *CustomCourseItem) GetSingle() (v bool) { + return p.Single +} + +func (p *CustomCourseItem) GetDouble_() (v bool) { + return p.Double_ +} + +var CustomCourseItem_Color_DEFAULT string + +func (p *CustomCourseItem) GetColor() (v string) { + if !p.IsSetColor() { + return CustomCourseItem_Color_DEFAULT + } + return *p.Color +} + +var CustomCourseItem_Remark_DEFAULT string + +func (p *CustomCourseItem) GetRemark() (v string) { + if !p.IsSetRemark() { + return CustomCourseItem_Remark_DEFAULT + } + return *p.Remark +} + +func (p *CustomCourseItem) IsSetID() bool { + return p.ID != nil +} + +func (p *CustomCourseItem) IsSetTeacher() bool { + return p.Teacher != nil +} + +func (p *CustomCourseItem) IsSetColor() bool { + return p.Color != nil +} + +func (p *CustomCourseItem) IsSetRemark() bool { + return p.Remark != nil +} + +func (p *CustomCourseItem) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CustomCourseItem(%+v)", *p) +} + type CourseListRequest struct { Term string `thrift:"term,1,required" form:"term,required" json:"term,required" query:"term,required"` IsRefresh *bool `thrift:"is_refresh,2,optional" form:"is_refresh" json:"is_refresh,omitempty" query:"is_refresh"` @@ -872,8 +990,9 @@ func (p *CourseListRequest) String() string { } type CourseListResponse struct { - Base *model.BaseResp `thrift:"base,1,required" form:"base,required" json:"base,required" query:"base,required"` - Data []*model.Course `thrift:"data,2,required,list" form:"data,required" json:"data,required" query:"data,required"` + Base *model.BaseResp `thrift:"base,1,required" form:"base,required" json:"base,required" query:"base,required"` + Data []*model.Course `thrift:"data,2,required,list" form:"data,required" json:"data,required" query:"data,required"` + CustomCourses []*CustomCourseItem `thrift:"custom_courses,3,optional,list" form:"custom_courses" json:"custom_courses,omitempty" query:"custom_courses"` } func NewCourseListResponse() *CourseListResponse { @@ -896,10 +1015,23 @@ func (p *CourseListResponse) GetData() (v []*model.Course) { return p.Data } +var CourseListResponse_CustomCourses_DEFAULT []*CustomCourseItem + +func (p *CourseListResponse) GetCustomCourses() (v []*CustomCourseItem) { + if !p.IsSetCustomCourses() { + return CourseListResponse_CustomCourses_DEFAULT + } + return p.CustomCourses +} + func (p *CourseListResponse) IsSetBase() bool { return p.Base != nil } +func (p *CourseListResponse) IsSetCustomCourses() bool { + return p.CustomCourses != nil +} + func (p *CourseListResponse) String() string { if p == nil { return "" @@ -907,6 +1039,83 @@ func (p *CourseListResponse) String() string { return fmt.Sprintf("CourseListResponse(%+v)", *p) } +type CourseListV2Request struct { + Term string `thrift:"term,1,required" form:"term,required" json:"term,required" query:"term,required"` + IsRefresh *bool `thrift:"is_refresh,2,optional" form:"is_refresh" json:"is_refresh,omitempty" query:"is_refresh"` +} + +func NewCourseListV2Request() *CourseListV2Request { + return &CourseListV2Request{} +} + +func (p *CourseListV2Request) InitDefault() { +} + +func (p *CourseListV2Request) GetTerm() (v string) { + return p.Term +} + +var CourseListV2Request_IsRefresh_DEFAULT bool + +func (p *CourseListV2Request) GetIsRefresh() (v bool) { + if !p.IsSetIsRefresh() { + return CourseListV2Request_IsRefresh_DEFAULT + } + return *p.IsRefresh +} + +func (p *CourseListV2Request) IsSetIsRefresh() bool { + return p.IsRefresh != nil +} + +func (p *CourseListV2Request) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CourseListV2Request(%+v)", *p) +} + +type CourseListV2Response struct { + Base *model.BaseResp `thrift:"base,1,required" form:"base,required" json:"base,required" query:"base,required"` + Data []*model.Course `thrift:"data,2,required,list" form:"data,required" json:"data,required" query:"data,required"` + CustomCourses []*CustomCourseItem `thrift:"custom_courses,3,required,list" form:"custom_courses,required" json:"custom_courses,required" query:"custom_courses,required"` +} + +func NewCourseListV2Response() *CourseListV2Response { + return &CourseListV2Response{} +} + +func (p *CourseListV2Response) InitDefault() { +} + +var CourseListV2Response_Base_DEFAULT *model.BaseResp + +func (p *CourseListV2Response) GetBase() (v *model.BaseResp) { + if !p.IsSetBase() { + return CourseListV2Response_Base_DEFAULT + } + return p.Base +} + +func (p *CourseListV2Response) GetData() (v []*model.Course) { + return p.Data +} + +func (p *CourseListV2Response) GetCustomCourses() (v []*CustomCourseItem) { + return p.CustomCourses +} + +func (p *CourseListV2Response) IsSetBase() bool { + return p.Base != nil +} + +func (p *CourseListV2Response) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CourseListV2Response(%+v)", *p) +} + type CourseTermListRequest struct { } @@ -1312,6 +1521,145 @@ func (p *UpdateAdjustCourseResponse) String() string { return fmt.Sprintf("UpdateAdjustCourseResponse(%+v)", *p) } +type UpsertCustomCourseRequest struct { + Term string `thrift:"term,1,required" form:"term,required" json:"term,required" query:"term,required"` + Course *CustomCourseItem `thrift:"course,2,required" form:"course,required" json:"course,required" query:"course,required"` +} + +func NewUpsertCustomCourseRequest() *UpsertCustomCourseRequest { + return &UpsertCustomCourseRequest{} +} + +func (p *UpsertCustomCourseRequest) InitDefault() { +} + +func (p *UpsertCustomCourseRequest) GetTerm() (v string) { + return p.Term +} + +var UpsertCustomCourseRequest_Course_DEFAULT *CustomCourseItem + +func (p *UpsertCustomCourseRequest) GetCourse() (v *CustomCourseItem) { + if !p.IsSetCourse() { + return UpsertCustomCourseRequest_Course_DEFAULT + } + return p.Course +} + +func (p *UpsertCustomCourseRequest) IsSetCourse() bool { + return p.Course != nil +} + +func (p *UpsertCustomCourseRequest) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("UpsertCustomCourseRequest(%+v)", *p) +} + +type UpsertCustomCourseResponse struct { + Base *model.BaseResp `thrift:"base,1,required" form:"base,required" json:"base,required" query:"base,required"` + CourseID *string `thrift:"course_id,2,optional" form:"course_id" json:"course_id,omitempty" query:"course_id"` +} + +func NewUpsertCustomCourseResponse() *UpsertCustomCourseResponse { + return &UpsertCustomCourseResponse{} +} + +func (p *UpsertCustomCourseResponse) InitDefault() { +} + +var UpsertCustomCourseResponse_Base_DEFAULT *model.BaseResp + +func (p *UpsertCustomCourseResponse) GetBase() (v *model.BaseResp) { + if !p.IsSetBase() { + return UpsertCustomCourseResponse_Base_DEFAULT + } + return p.Base +} + +var UpsertCustomCourseResponse_CourseID_DEFAULT string + +func (p *UpsertCustomCourseResponse) GetCourseID() (v string) { + if !p.IsSetCourseID() { + return UpsertCustomCourseResponse_CourseID_DEFAULT + } + return *p.CourseID +} + +func (p *UpsertCustomCourseResponse) IsSetBase() bool { + return p.Base != nil +} + +func (p *UpsertCustomCourseResponse) IsSetCourseID() bool { + return p.CourseID != nil +} + +func (p *UpsertCustomCourseResponse) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("UpsertCustomCourseResponse(%+v)", *p) +} + +type DeleteCustomCourseRequest struct { + Term string `thrift:"term,1,required" form:"term,required" json:"term,required" query:"term,required"` + CourseID string `thrift:"course_id,2,required" form:"course_id,required" json:"course_id,required" query:"course_id,required"` +} + +func NewDeleteCustomCourseRequest() *DeleteCustomCourseRequest { + return &DeleteCustomCourseRequest{} +} + +func (p *DeleteCustomCourseRequest) InitDefault() { +} + +func (p *DeleteCustomCourseRequest) GetTerm() (v string) { + return p.Term +} + +func (p *DeleteCustomCourseRequest) GetCourseID() (v string) { + return p.CourseID +} + +func (p *DeleteCustomCourseRequest) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("DeleteCustomCourseRequest(%+v)", *p) +} + +type DeleteCustomCourseResponse struct { + Base *model.BaseResp `thrift:"base,1,required" form:"base,required" json:"base,required" query:"base,required"` +} + +func NewDeleteCustomCourseResponse() *DeleteCustomCourseResponse { + return &DeleteCustomCourseResponse{} +} + +func (p *DeleteCustomCourseResponse) InitDefault() { +} + +var DeleteCustomCourseResponse_Base_DEFAULT *model.BaseResp + +func (p *DeleteCustomCourseResponse) GetBase() (v *model.BaseResp) { + if !p.IsSetBase() { + return DeleteCustomCourseResponse_Base_DEFAULT + } + return p.Base +} + +func (p *DeleteCustomCourseResponse) IsSetBase() bool { + return p.Base != nil +} + +func (p *DeleteCustomCourseResponse) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("DeleteCustomCourseResponse(%+v)", *p) +} + // # ---------------------------------------------------------------------------- // # launch_screen 开屏页 // # ---------------------------------------------------------------------------- @@ -5294,6 +5642,8 @@ type UserService interface { type CourseService interface { // 获取课表 GetCourseList(ctx context.Context, req *CourseListRequest) (r *CourseListResponse, err error) + // 获取课表 V2(响应始终包含 custom_courses) + GetCourseListV2(ctx context.Context, req *CourseListV2Request) (r *CourseListV2Response, err error) // 获取学期 GetTermList(ctx context.Context, req *CourseTermListRequest) (r *CourseTermListResponse, err error) // 获取日历订阅 token @@ -5308,6 +5658,10 @@ type CourseService interface { GetAutoAdjustCourseList(ctx context.Context, req *GetAutoAdjustCourseListRequest) (r *GetAutoAdjustCourseListResponse, err error) // 更新自动调课信息 UpdateAdjustCourse(ctx context.Context, req *UpdateAdjustCourseRequest) (r *UpdateAdjustCourseResponse, err error) + // 新增或更新自定义课程 + UpsertCustomCourse(ctx context.Context, req *UpsertCustomCourseRequest) (r *UpsertCustomCourseResponse, err error) + // 删除自定义课程 + DeleteCustomCourse(ctx context.Context, req *DeleteCustomCourseRequest) (r *DeleteCustomCourseResponse, err error) } type LaunchScreenService interface { diff --git a/api/model/model/model.go b/api/model/model/model.go index 073dd0e39..694c8a20e 100644 --- a/api/model/model/model.go +++ b/api/model/model/model.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Code generated by thriftgo (0.4.3). DO NOT EDIT. +// Code generated by thriftgo (0.4.5). DO NOT EDIT. package model diff --git a/api/pack/course.go b/api/pack/course.go index ee112298b..5e1bd86c9 100644 --- a/api/pack/course.go +++ b/api/pack/course.go @@ -17,7 +17,9 @@ limitations under the License. package pack import ( + api "github.com/west2-online/fzuhelper-server/api/model/api" courseModel "github.com/west2-online/fzuhelper-server/api/model/model" + courseKitex "github.com/west2-online/fzuhelper-server/kitex_gen/course" "github.com/west2-online/fzuhelper-server/kitex_gen/model" ) @@ -117,3 +119,56 @@ func BuildAdjustCourseList(res []*model.AdjustCourse) []*courseModel.AdjustCours } return list } + +func BuildCustomCourseItem(res *courseKitex.CustomCourseItem) *api.CustomCourseItem { + if res == nil { + return nil + } + return &api.CustomCourseItem{ + ID: res.Id, + Name: res.Name, + Teacher: res.Teacher, + Location: res.Location, + StartClass: res.StartClass, + EndClass: res.EndClass, + StartWeek: res.StartWeek, + EndWeek: res.EndWeek, + Weekday: res.Weekday, + Single: res.Single, + Double_: res.Double_, + Color: res.Color, + Remark: res.Remark, + } +} + +func BuildCustomCourseItemList(res []*courseKitex.CustomCourseItem) []*api.CustomCourseItem { + list := make([]*api.CustomCourseItem, 0, len(res)) + for _, v := range res { + c := BuildCustomCourseItem(v) + if c != nil { + list = append(list, c) + } + } + return list +} + +func BuildCustomCourseItemForRPC(res *api.CustomCourseItem) *courseKitex.CustomCourseItem { + if res == nil { + return nil + } + return &courseKitex.CustomCourseItem{ + Id: res.ID, + Name: res.Name, + Teacher: res.Teacher, + Location: res.Location, + StartClass: res.StartClass, + EndClass: res.EndClass, + StartWeek: res.StartWeek, + EndWeek: res.EndWeek, + Weekday: res.Weekday, + Single: res.Single, + Double_: res.Double_, + Color: res.Color, + Remark: res.Remark, + } +} diff --git a/api/router/api/api.go b/api/router/api/api.go index 5f2bf605b..ebeab4747 100644 --- a/api/router/api/api.go +++ b/api/router/api/api.go @@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -20,7 +20,6 @@ package api import ( "github.com/cloudwego/hertz/pkg/app/server" - api "github.com/west2-online/fzuhelper-server/api/handler/api" ) @@ -56,6 +55,8 @@ func Register(r *server.Hertz) { } { _course := _v1.Group("/course", _courseMw()...) + _course.DELETE("/custom", append(_deletecustomcourseMw(), api.DeleteCustomCourse)...) + _course.POST("/custom", append(_upsertcustomcourseMw(), api.UpsertCustomCourse)...) _course.GET("/date", append(_getlocatedateMw(), api.GetLocateDate)...) { _adjust := _course.Group("/adjust", _adjustMw()...) @@ -188,6 +189,10 @@ func Register(r *server.Hertz) { _common0.GET("/fzu-helper.html", append(_gethtmlMw(), api.GetHtml)...) _common0.GET("/user-agreement.html", append(_getuseragreementMw(), api.GetUserAgreement)...) } + { + _course1 := _v2.Group("/course", _course1Mw()...) + _course1.GET("/list", append(_getcourselistv2Mw(), api.GetCourseListV2)...) + } { _jwch0 := _v2.Group("/jwch", _jwch0Mw()...) { diff --git a/api/router/api/middleware.go b/api/router/api/middleware.go index 739844da9..6fe949f73 100644 --- a/api/router/api/middleware.go +++ b/api/router/api/middleware.go @@ -579,6 +579,20 @@ func _updateadjustcourseMw() []app.HandlerFunc { return nil } +func _upsertcustomcourseMw() []app.HandlerFunc { + return []app.HandlerFunc{ + mw.Auth(), + mw.GetHeaderParams(), + } +} + +func _deletecustomcourseMw() []app.HandlerFunc { + return []app.HandlerFunc{ + mw.Auth(), + mw.GetHeaderParams(), + } +} + func _getautoadjustcourselistMw() []app.HandlerFunc { // your code... return nil @@ -631,3 +645,13 @@ func _listimageMw() []app.HandlerFunc { // your code... return nil } + +func _course1Mw() []app.HandlerFunc { + // your code... + return nil +} + +func _getcourselistv2Mw() []app.HandlerFunc { + // your code... + return nil +} diff --git a/api/rpc/course.go b/api/rpc/course.go index 135419728..59fa30e73 100644 --- a/api/rpc/course.go +++ b/api/rpc/course.go @@ -35,7 +35,7 @@ func InitCourseRPC() { courseClient = *c } -func GetCourseListRPC(ctx context.Context, req *course.CourseListRequest) (courses []*model.Course, err error) { +func GetCourseListRPC(ctx context.Context, req *course.CourseListRequest) (*course.CourseListResponse, error) { resp, err := courseClient.GetCourseList(ctx, req) if err != nil { logger.WithCtx(ctx).Errorf("GetCourseListRPC: RPC called failed: %v", err.Error()) @@ -45,7 +45,7 @@ func GetCourseListRPC(ctx context.Context, req *course.CourseListRequest) (cours return nil, err } - return resp.Data, nil + return resp, nil } func GetCourseTermsListRPC(ctx context.Context, req *course.TermListRequest) (*course.TermListResponse, error) { @@ -121,3 +121,27 @@ func UpdateAutoAdjustCourseRPC(ctx context.Context, req *course.UpdateAdjustCour } return nil } + +func UpsertCustomCourseRPC(ctx context.Context, req *course.UpsertCustomCourseRequest) (*course.UpsertCustomCourseResponse, error) { + resp, err := courseClient.UpsertCustomCourse(ctx, req) + if err != nil { + logger.WithCtx(ctx).Errorf("UpsertCustomCourseRPC: RPC called failed: %v", err.Error()) + return nil, errno.InternalServiceError.WithMessage(err.Error()) + } + if err = utils.HandleBaseRespWithCookie(resp.Base); err != nil { + return nil, errno.BizError.WithMessage("保存自定义课程失败: " + resp.Base.Msg) + } + return resp, nil +} + +func DeleteCustomCourseRPC(ctx context.Context, req *course.DeleteCustomCourseRequest) error { + resp, err := courseClient.DeleteCustomCourse(ctx, req) + if err != nil { + logger.WithCtx(ctx).Errorf("DeleteCustomCourseRPC: RPC called failed: %v", err.Error()) + return errno.InternalServiceError.WithMessage(err.Error()) + } + if err = utils.HandleBaseRespWithCookie(resp.Base); err != nil { + return errno.BizError.WithMessage("删除自定义课程失败: " + resp.Base.Msg) + } + return nil +} diff --git a/config/sql/init.sql b/config/sql/init.sql index df5130be4..a4bcd242f 100644 --- a/config/sql/init.sql +++ b/config/sql/init.sql @@ -224,3 +224,29 @@ CREATE TABLE `fzu-helper`.`auto_adjust_course` ( INDEX `idx_to_date` (`to_date`), INDEX `idx_term` (`term`) ) ENGINE=InnoDB AUTO_INCREMENT=10000 DEFAULT CHARSET=utf8mb4 COMMENT='调课信息表'; + +CREATE TABLE `fzu-helper`.`user_custom_courses` ( + `stu_id` varchar(50) NOT NULL COMMENT '学号', + `term` varchar(20) NOT NULL COMMENT '学期', + `course_id` varchar(64) NOT NULL COMMENT '课程唯一ID(UUID)', + `name` varchar(100) NOT NULL COMMENT '课程名称', + `teacher` varchar(50) NULL DEFAULT '' COMMENT '教师', + `location` varchar(100) NOT NULL COMMENT '上课地点', + `start_class` int NOT NULL COMMENT '开始节次', + `end_class` int NOT NULL COMMENT '结束节次', + `start_week` int NOT NULL COMMENT '开始周', + `end_week` int NOT NULL COMMENT '结束周', + `weekday` int NOT NULL COMMENT '星期 1-7', + `is_single` tinyint(1) NOT NULL DEFAULT 0 COMMENT '单周上课', + `is_double` tinyint(1) NOT NULL DEFAULT 0 COMMENT '双周上课', + `color` varchar(20) NULL DEFAULT '#FF5733' COMMENT '课程颜色', + `remark` varchar(200) NULL DEFAULT '' COMMENT '备注', + `created_at` datetime NULL DEFAULT current_timestamp, + `updated_at` datetime NULL DEFAULT current_timestamp ON UPDATE current_timestamp, + `deleted_at` datetime NULL DEFAULT NULL, + PRIMARY KEY (`course_id`), + UNIQUE KEY `uk_stu_term_course` (`stu_id`, `term`, `course_id`), + INDEX `idx_stu` (`stu_id`), + INDEX `idx_term` (`term`), + INDEX `idx_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户自定义课程表'; diff --git a/go.mod b/go.mod index 091405105..efa8344e9 100644 --- a/go.mod +++ b/go.mod @@ -105,7 +105,7 @@ require ( github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect diff --git a/idl/api.thrift b/idl/api.thrift index df7b055ed..8881a1c15 100644 --- a/idl/api.thrift +++ b/idl/api.thrift @@ -175,6 +175,22 @@ service UserService { ## ---------------------------------------------------------------------------- ## course 课表 ## ---------------------------------------------------------------------------- +struct CustomCourseItem { + 1: optional string id + 2: required string name + 3: optional string teacher + 4: required string location + 5: required i32 start_class + 6: required i32 end_class + 7: required i32 start_week + 8: required i32 end_week + 9: required i32 weekday + 10: required bool single + 11: required bool double_ + 12: optional string color + 13: optional string remark +} + struct CourseListRequest { 1: required string term 2: optional bool is_refresh @@ -183,6 +199,18 @@ struct CourseListRequest { struct CourseListResponse { 1: required model.BaseResp base 2: required list data + 3: optional list custom_courses +} + +struct CourseListV2Request { + 1: required string term + 2: optional bool is_refresh +} + +struct CourseListV2Response { + 1: required model.BaseResp base + 2: required list data + 3: required list custom_courses } struct CourseTermListRequest{} @@ -244,9 +272,30 @@ struct UpdateAdjustCourseResponse { 1: required model.BaseResp base } +struct UpsertCustomCourseRequest { + 1: required string term + 2: required CustomCourseItem course +} + +struct UpsertCustomCourseResponse { + 1: required model.BaseResp base + 2: optional string course_id +} + +struct DeleteCustomCourseRequest { + 1: required string term + 2: required string course_id +} + +struct DeleteCustomCourseResponse { + 1: required model.BaseResp base +} + service CourseService { // 获取课表 CourseListResponse GetCourseList(1: CourseListRequest req)(api.get="/api/v1/jwch/course/list") + // 获取课表 V2(响应始终包含 custom_courses) + CourseListV2Response GetCourseListV2(1: CourseListV2Request req)(api.get="/api/v2/course/list") // 获取学期 CourseTermListResponse GetTermList(1: CourseTermListRequest req)(api.get="/api/v1/jwch/term/list") // 获取日历订阅 token @@ -262,6 +311,10 @@ service CourseService { GetAutoAdjustCourseListResponse GetAutoAdjustCourseList(1: GetAutoAdjustCourseListRequest req)(api.get="/api/v1/course/adjust/list") // 更新自动调课信息 UpdateAdjustCourseResponse UpdateAdjustCourse(1: UpdateAdjustCourseRequest req)(api.put="/api/v1/course/adjust/") + // 新增或更新自定义课程 + UpsertCustomCourseResponse UpsertCustomCourse(1: UpsertCustomCourseRequest req)(api.post="/api/v1/course/custom") + // 删除自定义课程 + DeleteCustomCourseResponse DeleteCustomCourse(1: DeleteCustomCourseRequest req)(api.delete="/api/v1/course/custom") } ## ---------------------------------------------------------------------------- diff --git a/idl/course.thrift b/idl/course.thrift index 2608b1af8..dcff74f0e 100644 --- a/idl/course.thrift +++ b/idl/course.thrift @@ -16,6 +16,7 @@ struct CourseListRequest { struct CourseListResponse { 1: required model.BaseResp base 2: required list data + 3: optional list customCourses } struct GetCalendarRequest { @@ -64,6 +65,41 @@ struct UpdateAdjustCourseResponse { 1: required model.BaseResp base } +struct CustomCourseItem { + 1: optional string id + 2: required string name + 3: optional string teacher + 4: required string location + 5: required i32 startClass + 6: required i32 endClass + 7: required i32 startWeek + 8: required i32 endWeek + 9: required i32 weekday + 10: required bool single + 11: required bool double_ + 12: optional string color + 13: optional string remark +} + +struct UpsertCustomCourseRequest { + 1: required string term + 2: required CustomCourseItem course +} + +struct UpsertCustomCourseResponse { + 1: required model.BaseResp base + 2: optional string courseId +} + +struct DeleteCustomCourseRequest { + 1: required string term + 2: required string courseId +} + +struct DeleteCustomCourseResponse { + 1: required model.BaseResp base +} + service CourseService { CourseListResponse GetCourseList(1: CourseListRequest req) TermListResponse GetTermList(1: TermListRequest req) @@ -72,4 +108,8 @@ service CourseService { GetFriendCourseResponse GetFriendCourse(1: GetFriendCourseRequest req) GetAutoAdjustCourseListResponse GetAutoAdjustCourseList(1: GetAutoAdjustCourseListRequest req) UpdateAdjustCourseResponse UpdateAdjustCourse(1: UpdateAdjustCourseRequest req) + + // 自定义课程接口 + UpsertCustomCourseResponse UpsertCustomCourse(1: UpsertCustomCourseRequest req) + DeleteCustomCourseResponse DeleteCustomCourse(1: DeleteCustomCourseRequest req) } diff --git a/internal/course/handler.go b/internal/course/handler.go index 3d9ed0cdb..255876ee5 100644 --- a/internal/course/handler.go +++ b/internal/course/handler.go @@ -27,6 +27,7 @@ import ( "github.com/west2-online/fzuhelper-server/pkg/base" metainfoContext "github.com/west2-online/fzuhelper-server/pkg/base/context" "github.com/west2-online/fzuhelper-server/pkg/constants" + "github.com/west2-online/fzuhelper-server/pkg/logger" "github.com/west2-online/fzuhelper-server/pkg/singleflight" "github.com/west2-online/fzuhelper-server/pkg/taskqueue" "github.com/west2-online/fzuhelper-server/pkg/utils" @@ -73,6 +74,49 @@ func (s *CourseServiceImpl) GetCourseList(ctx context.Context, req *course.Cours } resp.Base = base.BuildSuccessResp() resp.Data = res + + customCourses, err := service.NewCourseService(ctx, s.ClientSet, s.taskQueue).GetCustomCourses(ctx, stuId, req.Term) + if err != nil { + logger.WithCtx(ctx).Errorf("get custom courses failed (fallback to empty): %v", err) + resp.CustomCourses = nil + } else { + resp.CustomCourses = customCourses + } + + return resp, nil +} + +func (s *CourseServiceImpl) UpsertCustomCourse(ctx context.Context, req *course.UpsertCustomCourseRequest) ( + resp *course.UpsertCustomCourseResponse, err error, +) { + resp = course.NewUpsertCustomCourseResponse() + loginData, err := metainfoContext.GetLoginData(ctx) + if err != nil { + return nil, fmt.Errorf("Course.UpsertCustomCourse: Get login data fail %w", err) + } + stuId := metainfoContext.ExtractIDFromLoginData(loginData) + courseID, err := service.NewCourseService(ctx, s.ClientSet, s.taskQueue).UpsertCustomCourse(ctx, stuId, req) + if err != nil { + resp.Base = base.BuildBaseResp(err) + return resp, nil + } + + resp.Base = base.BuildSuccessResp() + resp.CourseId = &courseID + return resp, nil +} + +func (s *CourseServiceImpl) DeleteCustomCourse(ctx context.Context, req *course.DeleteCustomCourseRequest) ( + resp *course.DeleteCustomCourseResponse, err error, +) { + resp = course.NewDeleteCustomCourseResponse() + loginData, err := metainfoContext.GetLoginData(ctx) + if err != nil { + return nil, fmt.Errorf("Course.DeleteCustomCourse: Get login data fail %w", err) + } + stuId := metainfoContext.ExtractIDFromLoginData(loginData) + err = service.NewCourseService(ctx, s.ClientSet, s.taskQueue).DeleteCustomCourse(ctx, stuId, req) + resp.Base = base.BuildBaseResp(err) return resp, nil } diff --git a/internal/course/pack/custom_course.go b/internal/course/pack/custom_course.go new file mode 100644 index 000000000..90ed49d1f --- /dev/null +++ b/internal/course/pack/custom_course.go @@ -0,0 +1,52 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pack + +import ( + courseKitex "github.com/west2-online/fzuhelper-server/kitex_gen/course" + dbModel "github.com/west2-online/fzuhelper-server/pkg/db/model" +) + +// BuildCustomCourseItems 将数据库模型转换为 Thrift 类型 +func BuildCustomCourseItems(courses []*dbModel.UserCustomCourse) []*courseKitex.CustomCourseItem { + result := make([]*courseKitex.CustomCourseItem, 0, len(courses)) + for _, c := range courses { + item := &courseKitex.CustomCourseItem{ + Id: &c.CourseId, + Name: c.Name, + Location: c.Location, + StartClass: int32(c.StartClass), + EndClass: int32(c.EndClass), + StartWeek: int32(c.StartWeek), + EndWeek: int32(c.EndWeek), + Weekday: int32(c.Weekday), + } + if c.Teacher != "" { + item.Teacher = &c.Teacher + } + item.Single = c.IsSingle + item.Double_ = c.IsDouble + if c.Color != "" { + item.Color = &c.Color + } + if c.Remark != "" { + item.Remark = &c.Remark + } + result = append(result, item) + } + return result +} diff --git a/internal/course/service/custom_course.go b/internal/course/service/custom_course.go new file mode 100644 index 000000000..3e2a2116e --- /dev/null +++ b/internal/course/service/custom_course.go @@ -0,0 +1,156 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/internal/course/pack" + "github.com/west2-online/fzuhelper-server/kitex_gen/course" + "github.com/west2-online/fzuhelper-server/pkg/db/model" + "github.com/west2-online/fzuhelper-server/pkg/errno" +) + +func (s *CourseService) GetCustomCourses(ctx context.Context, stuID, term string) ([]*course.CustomCourseItem, error) { + courses, err := s.db.Course.GetCustomCourses(ctx, stuID, term) + if err != nil { + return nil, err + } + return pack.BuildCustomCourseItems(courses), nil +} + +func (s *CourseService) UpsertCustomCourse(ctx context.Context, stuID string, req *course.UpsertCustomCourseRequest) (string, error) { + item := req.Course + if item.Id != nil && *item.Id != "" { + return s.updateCustomCourse(ctx, stuID, req.Term, *item.Id, item) + } + + isDuplicate, existingID, err := s.db.Course.CheckDuplicateCustomCourse(ctx, stuID, req.Term, + item.Name, item.Location, + int(item.StartClass), int(item.EndClass), + int(item.StartWeek), int(item.EndWeek), + int(item.Weekday), item.Single, item.Double_) + if err != nil { + return "", err + } + if isDuplicate { + return existingID, nil + } + + courseID := uuid.New().String() + customCourse := &model.UserCustomCourse{ + StuId: stuID, + Term: req.Term, + CourseId: courseID, + Name: item.Name, + Teacher: getStringValue(item.Teacher), + Location: item.Location, + StartClass: int(item.StartClass), + EndClass: int(item.EndClass), + StartWeek: int(item.StartWeek), + EndWeek: int(item.EndWeek), + Weekday: int(item.Weekday), + IsSingle: item.Single, + IsDouble: item.Double_, + Color: getStringValueWithDefault(item.Color, "#FF5733"), + Remark: getStringValue(item.Remark), + } + if err := s.db.Course.CreateCustomCourse(ctx, customCourse); err != nil { + return "", err + } + return courseID, nil +} + +func (s *CourseService) updateCustomCourse( + ctx context.Context, + stuID, term, courseID string, + item *course.CustomCourseItem, +) (string, error) { + old, err := s.db.Course.GetCustomCourseByID(ctx, stuID, term, courseID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", errno.CustomCourseNotFoundError + } + return "", err + } + + teacher := old.Teacher + if item.Teacher != nil { + teacher = *item.Teacher + } + single := item.Single + double := item.Double_ + color := old.Color + if item.Color != nil { + color = *item.Color + } + remark := old.Remark + if item.Remark != nil { + remark = *item.Remark + } + + rows, err := s.db.Course.UpdateCustomCourse(ctx, stuID, term, courseID, map[string]interface{}{ + "name": item.Name, + "teacher": teacher, + "location": item.Location, + "start_class": int(item.StartClass), + "end_class": int(item.EndClass), + "start_week": int(item.StartWeek), + "end_week": int(item.EndWeek), + "weekday": int(item.Weekday), + "is_single": single, + "is_double": double, + "color": color, + "remark": remark, + }) + if err != nil { + return "", err + } + if rows == 0 { + return "", errno.CustomCourseNotFoundError + } + return courseID, nil +} + +func (s *CourseService) DeleteCustomCourse(ctx context.Context, stuID string, req *course.DeleteCustomCourseRequest) error { + rows, err := s.db.Course.DeleteCustomCourse(ctx, stuID, req.Term, req.CourseId) + if err != nil { + return errno.InternalServiceError.WithError(err) + } + if rows == 0 { + return errno.CustomCourseNotFoundError + } + return nil +} + +func getStringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func getStringValueWithDefault(value *string, defaultValue string) string { + if value == nil || *value == "" { + return defaultValue + } + return *value +} diff --git a/internal/course/service/custom_course_test.go b/internal/course/service/custom_course_test.go new file mode 100644 index 000000000..54191edea --- /dev/null +++ b/internal/course/service/custom_course_test.go @@ -0,0 +1,557 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/internal/course/pack" + "github.com/west2-online/fzuhelper-server/kitex_gen/course" + "github.com/west2-online/fzuhelper-server/pkg/base" + "github.com/west2-online/fzuhelper-server/pkg/cache" + "github.com/west2-online/fzuhelper-server/pkg/db" + dbcourse "github.com/west2-online/fzuhelper-server/pkg/db/course" + dbmodel "github.com/west2-online/fzuhelper-server/pkg/db/model" + "github.com/west2-online/fzuhelper-server/pkg/taskqueue" + "github.com/west2-online/fzuhelper-server/pkg/utils" +) + +const ( + mockStuID = "102301517" + mockTerm = "202401" + mockCourseID = "course-uuid-1" +) + +func TestGetCustomCourses(t *testing.T) { + mockCourses := []*dbmodel.UserCustomCourse{ + { + StuId: mockStuID, + Term: mockTerm, + CourseId: mockCourseID, + Name: "自习", + Teacher: "张老师", + Location: "图书馆3楼", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + IsSingle: false, + IsDouble: true, + Color: "#FF5733", + Remark: "期末复习", + }, + } + + type testCase struct { + name string + mockCourses []*dbmodel.UserCustomCourse + mockErr error + expectErr string + expectLen int + } + + testCases := []testCase{ + { + name: "GetCustomCoursesSuccess", + mockCourses: mockCourses, + expectLen: 1, + }, + { + name: "GetCustomCoursesEmpty", + mockCourses: []*dbmodel.UserCustomCourse{}, + }, + { + name: "GetCustomCoursesDBError", + mockErr: assert.AnError, + expectErr: "assert.AnError", + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + + mockey.Mock((*dbcourse.DBCourse).GetCustomCourses).Return(tc.mockCourses, tc.mockErr).Build() + + courseService := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)) + res, err := courseService.GetCustomCourses(context.Background(), mockStuID, mockTerm) + + if tc.expectErr != "" { + assert.ErrorContains(t, err, tc.expectErr) + assert.Nil(t, res) + } else { + assert.NoError(t, err) + assert.Len(t, res, tc.expectLen) + assert.Equal(t, pack.BuildCustomCourseItems(tc.mockCourses), res) + } + }) + } +} + +func TestUpsertCustomCourse(t *testing.T) { + baseItem := &course.CustomCourseItem{ + Name: "自习", + Teacher: new("张老师"), + Location: "图书馆3楼", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + Single: false, + Double_: true, + Color: new("#00FF66"), + Remark: new("期末复习"), + } + + itemWithID := &course.CustomCourseItem{ + Id: new(mockCourseID), + Name: "自习(更新)", + Teacher: new("张老师"), + Location: "图书馆4楼", + StartClass: 3, + EndClass: 4, + StartWeek: 1, + EndWeek: 16, + Weekday: 2, + Single: true, + Double_: false, + Color: new("#222222"), + Remark: new("新备注"), + } + + type testCase struct { + name string + item *course.CustomCourseItem + updateID string + updateErr error + checkDuplicate bool + existingID string + checkErr error + createErr error + expectErr string + expectCreated *dbmodel.UserCustomCourse + } + + testCases := []testCase{ + { + name: "UpsertCustomCourseUpdateSuccess", + item: itemWithID, + updateID: mockCourseID, + }, + { + name: "UpsertCustomCourseUpdateError", + item: itemWithID, + updateErr: assert.AnError, + expectErr: "assert.AnError", + }, + { + name: "UpsertCustomCourseDuplicateCheckError", + item: baseItem, + checkErr: assert.AnError, + expectErr: "assert.AnError", + }, + { + name: "UpsertCustomCourseDuplicateReturnExistingID", + item: baseItem, + checkDuplicate: true, + existingID: "existing-uuid", + }, + { + name: "UpsertCustomCourseCreateSuccess", + item: baseItem, + expectCreated: &dbmodel.UserCustomCourse{ + StuId: mockStuID, + Term: mockTerm, + Name: "自习", + Teacher: "张老师", + Location: "图书馆3楼", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + IsSingle: false, + IsDouble: true, + Color: "#00FF66", + Remark: "期末复习", + }, + }, + { + name: "UpsertCustomCourseCreateWithDefaultColor", + item: &course.CustomCourseItem{ + Name: "自习", + Location: "图书馆3楼", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + }, + expectCreated: &dbmodel.UserCustomCourse{ + StuId: mockStuID, + Term: mockTerm, + Name: "自习", + Location: "图书馆3楼", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + Color: "#FF5733", + }, + }, + { + name: "UpsertCustomCourseCreateDBError", + item: baseItem, + createErr: assert.AnError, + expectErr: "assert.AnError", + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + + req := &course.UpsertCustomCourseRequest{Term: mockTerm, Course: tc.item} + var created *dbmodel.UserCustomCourse + + if tc.item.Id != nil && *tc.item.Id != "" { + mockey.Mock((*CourseService).updateCustomCourse).Return(tc.updateID, tc.updateErr).Build() + } else { + mockey.Mock((*dbcourse.DBCourse).CheckDuplicateCustomCourse). + Return(tc.checkDuplicate, tc.existingID, tc.checkErr).Build() + mockey.Mock((*dbcourse.DBCourse).CreateCustomCourse).To( + func(_ context.Context, course *dbmodel.UserCustomCourse) error { + created = course + return tc.createErr + }, + ).Build() + } + + courseService := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)) + res, err := courseService.UpsertCustomCourse(context.Background(), mockStuID, req) + + if tc.expectErr != "" { + assert.ErrorContains(t, err, tc.expectErr) + return + } + assert.NoError(t, err) + + if tc.item.Id != nil && *tc.item.Id != "" { + assert.Equal(t, tc.updateID, res) + assert.Nil(t, created) + return + } + if tc.checkDuplicate { + assert.Equal(t, tc.existingID, res) + assert.Nil(t, created) + return + } + assert.NotEmpty(t, res) + assert.NotNil(t, created) + assert.Equal(t, res, created.CourseId) + createdForCompare := *created + createdForCompare.CourseId = "" + assert.Equal(t, tc.expectCreated, &createdForCompare) + }) + } +} + +func TestUpdateCustomCourse(t *testing.T) { + oldCourse := &dbmodel.UserCustomCourse{ + StuId: mockStuID, + Term: mockTerm, + CourseId: mockCourseID, + Name: "自习", + Teacher: "旧老师", + Location: "旧地点", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + IsSingle: false, + IsDouble: true, + Color: "#111111", + Remark: "旧备注", + } + + overrideItem := &course.CustomCourseItem{ + Name: "自习(新)", + Teacher: new("新老师"), + Location: "新地点", + StartClass: 3, + EndClass: 4, + StartWeek: 2, + EndWeek: 15, + Weekday: 3, + Single: true, + Double_: false, + Color: new("#222222"), + Remark: new("新备注"), + } + + partialItem := &course.CustomCourseItem{ + Name: "自习(部分)", + Location: "图书馆", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + } + + type testCase struct { + name string + item *course.CustomCourseItem + mockOld *dbmodel.UserCustomCourse + mockGetErr error + mockUpdateRows int64 + mockUpdateErr error + expectErr string + expectUpdates map[string]interface{} + } + + testCases := []testCase{ + { + name: "UpdateCustomCourseNotFound", + item: overrideItem, + mockGetErr: gorm.ErrRecordNotFound, + expectErr: "自定义课程不存在", + }, + { + name: "UpdateCustomCourseGetDBError", + item: overrideItem, + mockGetErr: assert.AnError, + expectErr: "assert.AnError", + }, + { + name: "UpdateCustomCourseOverrideAllFields", + item: overrideItem, + mockOld: oldCourse, + mockUpdateRows: 1, + expectUpdates: map[string]interface{}{ + "name": "自习(新)", + "teacher": "新老师", + "location": "新地点", + "start_class": 3, + "end_class": 4, + "start_week": 2, + "end_week": 15, + "weekday": 3, + "is_single": true, + "is_double": false, + "color": "#222222", + "remark": "新备注", + }, + }, + { + name: "UpdateCustomCourseKeepOldValueWhenNil", + item: partialItem, + mockOld: oldCourse, + mockUpdateRows: 1, + expectUpdates: map[string]interface{}{ + "name": "自习(部分)", + "teacher": "旧老师", + "location": "图书馆", + "start_class": 1, + "end_class": 2, + "start_week": 1, + "end_week": 16, + "weekday": 1, + "is_single": false, + "is_double": false, + "color": "#111111", + "remark": "旧备注", + }, + }, + { + name: "UpdateCustomCourseRowsZero", + item: overrideItem, + mockOld: oldCourse, + mockUpdateRows: 0, + expectErr: "自定义课程不存在", + }, + { + name: "UpdateCustomCourseUpdateDBError", + item: overrideItem, + mockOld: oldCourse, + mockUpdateRows: 0, + mockUpdateErr: assert.AnError, + expectErr: "assert.AnError", + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + + mockey.Mock((*dbcourse.DBCourse).GetCustomCourseByID).Return(tc.mockOld, tc.mockGetErr).Build() + + var captured map[string]interface{} + mockey.Mock((*dbcourse.DBCourse).UpdateCustomCourse).To( + func(_ context.Context, _, _, _ string, updates map[string]interface{}) (int64, error) { + captured = updates + return tc.mockUpdateRows, tc.mockUpdateErr + }, + ).Build() + + courseService := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)) + res, err := courseService.updateCustomCourse(context.Background(), mockStuID, mockTerm, mockCourseID, tc.item) + + if tc.expectErr != "" { + assert.ErrorContains(t, err, tc.expectErr) + return + } + assert.NoError(t, err) + assert.Equal(t, mockCourseID, res) + assert.Equal(t, tc.expectUpdates, captured) + }) + } +} + +func TestDeleteCustomCourse(t *testing.T) { + type testCase struct { + name string + mockRows int64 + mockErr error + expectErr string + } + + testCases := []testCase{ + { + name: "DeleteCustomCourseSuccess", + mockRows: 1, + }, + { + name: "DeleteCustomCourseNotFound", + mockRows: 0, + expectErr: "自定义课程不存在", + }, + { + name: "DeleteCustomCourseDBError", + mockRows: 0, + mockErr: assert.AnError, + expectErr: "assert.AnError", + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + + mockey.Mock((*dbcourse.DBCourse).DeleteCustomCourse).Return(tc.mockRows, tc.mockErr).Build() + + courseService := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)) + err := courseService.DeleteCustomCourse(context.Background(), mockStuID, &course.DeleteCustomCourseRequest{ + Term: mockTerm, + CourseId: mockCourseID, + }) + + if tc.expectErr != "" { + assert.ErrorContains(t, err, tc.expectErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetStringValue(t *testing.T) { + testCases := []struct { + name string + value *string + expected string + }{ + { + name: "nil returns empty string", + value: nil, + }, + { + name: "non-nil returns value", + value: new("张老师"), + expected: "张老师", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, getStringValue(tc.value)) + }) + } +} + +func TestGetStringValueWithDefault(t *testing.T) { + testCases := []struct { + name string + value *string + defaultValue string + expected string + }{ + { + name: "nil uses default", + value: nil, + defaultValue: "#FF5733", + expected: "#FF5733", + }, + { + name: "empty uses default", + value: new(""), + defaultValue: "#FF5733", + expected: "#FF5733", + }, + { + name: "non-empty keeps value", + value: new("#00FF66"), + defaultValue: "#FF5733", + expected: "#00FF66", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, getStringValueWithDefault(tc.value, tc.defaultValue)) + }) + } +} diff --git a/kitex_gen/course/course.go b/kitex_gen/course/course.go index 34107b42c..ab91da70b 100644 --- a/kitex_gen/course/course.go +++ b/kitex_gen/course/course.go @@ -1,27 +1,10 @@ -/* -Copyright 2024 The west2-online Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Code generated by thriftgo (0.4.3). DO NOT EDIT. +// Code generated by thriftgo (0.4.5). DO NOT EDIT. package course import ( "context" "fmt" - "github.com/west2-online/fzuhelper-server/kitex_gen/model" ) @@ -127,8 +110,9 @@ func (p *CourseListRequest) String() string { } type CourseListResponse struct { - Base *model.BaseResp `thrift:"base,1,required" frugal:"1,required,model.BaseResp" json:"base"` - Data []*model.Course `thrift:"data,2,required" frugal:"2,required,list" json:"data"` + Base *model.BaseResp `thrift:"base,1,required" frugal:"1,required,model.BaseResp" json:"base"` + Data []*model.Course `thrift:"data,2,required" frugal:"2,required,list" json:"data"` + CustomCourses []*CustomCourseItem `thrift:"customCourses,3,optional" frugal:"3,optional,list" json:"customCourses,omitempty"` } func NewCourseListResponse() *CourseListResponse { @@ -150,17 +134,33 @@ func (p *CourseListResponse) GetBase() (v *model.BaseResp) { func (p *CourseListResponse) GetData() (v []*model.Course) { return p.Data } + +var CourseListResponse_CustomCourses_DEFAULT []*CustomCourseItem + +func (p *CourseListResponse) GetCustomCourses() (v []*CustomCourseItem) { + if !p.IsSetCustomCourses() { + return CourseListResponse_CustomCourses_DEFAULT + } + return p.CustomCourses +} func (p *CourseListResponse) SetBase(val *model.BaseResp) { p.Base = val } func (p *CourseListResponse) SetData(val []*model.Course) { p.Data = val } +func (p *CourseListResponse) SetCustomCourses(val []*CustomCourseItem) { + p.CustomCourses = val +} func (p *CourseListResponse) IsSetBase() bool { return p.Base != nil } +func (p *CourseListResponse) IsSetCustomCourses() bool { + return p.CustomCourses != nil +} + func (p *CourseListResponse) String() string { if p == nil { return "" @@ -563,6 +563,323 @@ func (p *UpdateAdjustCourseResponse) String() string { return fmt.Sprintf("UpdateAdjustCourseResponse(%+v)", *p) } +type CustomCourseItem struct { + Id *string `thrift:"id,1,optional" frugal:"1,optional,string" json:"id,omitempty"` + Name string `thrift:"name,2,required" frugal:"2,required,string" json:"name"` + Teacher *string `thrift:"teacher,3,optional" frugal:"3,optional,string" json:"teacher,omitempty"` + Location string `thrift:"location,4,required" frugal:"4,required,string" json:"location"` + StartClass int32 `thrift:"startClass,5,required" frugal:"5,required,i32" json:"startClass"` + EndClass int32 `thrift:"endClass,6,required" frugal:"6,required,i32" json:"endClass"` + StartWeek int32 `thrift:"startWeek,7,required" frugal:"7,required,i32" json:"startWeek"` + EndWeek int32 `thrift:"endWeek,8,required" frugal:"8,required,i32" json:"endWeek"` + Weekday int32 `thrift:"weekday,9,required" frugal:"9,required,i32" json:"weekday"` + Single bool `thrift:"single,10,required" frugal:"10,required,bool" json:"single"` + Double_ bool `thrift:"double_,11,required" frugal:"11,required,bool" json:"double_"` + Color *string `thrift:"color,12,optional" frugal:"12,optional,string" json:"color,omitempty"` + Remark *string `thrift:"remark,13,optional" frugal:"13,optional,string" json:"remark,omitempty"` +} + +func NewCustomCourseItem() *CustomCourseItem { + return &CustomCourseItem{} +} + +func (p *CustomCourseItem) InitDefault() { +} + +var CustomCourseItem_Id_DEFAULT string + +func (p *CustomCourseItem) GetId() (v string) { + if !p.IsSetId() { + return CustomCourseItem_Id_DEFAULT + } + return *p.Id +} + +func (p *CustomCourseItem) GetName() (v string) { + return p.Name +} + +var CustomCourseItem_Teacher_DEFAULT string + +func (p *CustomCourseItem) GetTeacher() (v string) { + if !p.IsSetTeacher() { + return CustomCourseItem_Teacher_DEFAULT + } + return *p.Teacher +} + +func (p *CustomCourseItem) GetLocation() (v string) { + return p.Location +} + +func (p *CustomCourseItem) GetStartClass() (v int32) { + return p.StartClass +} + +func (p *CustomCourseItem) GetEndClass() (v int32) { + return p.EndClass +} + +func (p *CustomCourseItem) GetStartWeek() (v int32) { + return p.StartWeek +} + +func (p *CustomCourseItem) GetEndWeek() (v int32) { + return p.EndWeek +} + +func (p *CustomCourseItem) GetWeekday() (v int32) { + return p.Weekday +} + +func (p *CustomCourseItem) GetSingle() (v bool) { + return p.Single +} + +func (p *CustomCourseItem) GetDouble_() (v bool) { + return p.Double_ +} + +var CustomCourseItem_Color_DEFAULT string + +func (p *CustomCourseItem) GetColor() (v string) { + if !p.IsSetColor() { + return CustomCourseItem_Color_DEFAULT + } + return *p.Color +} + +var CustomCourseItem_Remark_DEFAULT string + +func (p *CustomCourseItem) GetRemark() (v string) { + if !p.IsSetRemark() { + return CustomCourseItem_Remark_DEFAULT + } + return *p.Remark +} +func (p *CustomCourseItem) SetId(val *string) { + p.Id = val +} +func (p *CustomCourseItem) SetName(val string) { + p.Name = val +} +func (p *CustomCourseItem) SetTeacher(val *string) { + p.Teacher = val +} +func (p *CustomCourseItem) SetLocation(val string) { + p.Location = val +} +func (p *CustomCourseItem) SetStartClass(val int32) { + p.StartClass = val +} +func (p *CustomCourseItem) SetEndClass(val int32) { + p.EndClass = val +} +func (p *CustomCourseItem) SetStartWeek(val int32) { + p.StartWeek = val +} +func (p *CustomCourseItem) SetEndWeek(val int32) { + p.EndWeek = val +} +func (p *CustomCourseItem) SetWeekday(val int32) { + p.Weekday = val +} +func (p *CustomCourseItem) SetSingle(val bool) { + p.Single = val +} +func (p *CustomCourseItem) SetDouble_(val bool) { + p.Double_ = val +} +func (p *CustomCourseItem) SetColor(val *string) { + p.Color = val +} +func (p *CustomCourseItem) SetRemark(val *string) { + p.Remark = val +} + +func (p *CustomCourseItem) IsSetId() bool { + return p.Id != nil +} + +func (p *CustomCourseItem) IsSetTeacher() bool { + return p.Teacher != nil +} + +func (p *CustomCourseItem) IsSetColor() bool { + return p.Color != nil +} + +func (p *CustomCourseItem) IsSetRemark() bool { + return p.Remark != nil +} + +func (p *CustomCourseItem) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CustomCourseItem(%+v)", *p) +} + +type UpsertCustomCourseRequest struct { + Term string `thrift:"term,1,required" frugal:"1,required,string" json:"term"` + Course *CustomCourseItem `thrift:"course,2,required" frugal:"2,required,CustomCourseItem" json:"course"` +} + +func NewUpsertCustomCourseRequest() *UpsertCustomCourseRequest { + return &UpsertCustomCourseRequest{} +} + +func (p *UpsertCustomCourseRequest) InitDefault() { +} + +func (p *UpsertCustomCourseRequest) GetTerm() (v string) { + return p.Term +} + +var UpsertCustomCourseRequest_Course_DEFAULT *CustomCourseItem + +func (p *UpsertCustomCourseRequest) GetCourse() (v *CustomCourseItem) { + if !p.IsSetCourse() { + return UpsertCustomCourseRequest_Course_DEFAULT + } + return p.Course +} +func (p *UpsertCustomCourseRequest) SetTerm(val string) { + p.Term = val +} +func (p *UpsertCustomCourseRequest) SetCourse(val *CustomCourseItem) { + p.Course = val +} + +func (p *UpsertCustomCourseRequest) IsSetCourse() bool { + return p.Course != nil +} + +func (p *UpsertCustomCourseRequest) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("UpsertCustomCourseRequest(%+v)", *p) +} + +type UpsertCustomCourseResponse struct { + Base *model.BaseResp `thrift:"base,1,required" frugal:"1,required,model.BaseResp" json:"base"` + CourseId *string `thrift:"courseId,2,optional" frugal:"2,optional,string" json:"courseId,omitempty"` +} + +func NewUpsertCustomCourseResponse() *UpsertCustomCourseResponse { + return &UpsertCustomCourseResponse{} +} + +func (p *UpsertCustomCourseResponse) InitDefault() { +} + +var UpsertCustomCourseResponse_Base_DEFAULT *model.BaseResp + +func (p *UpsertCustomCourseResponse) GetBase() (v *model.BaseResp) { + if !p.IsSetBase() { + return UpsertCustomCourseResponse_Base_DEFAULT + } + return p.Base +} + +var UpsertCustomCourseResponse_CourseId_DEFAULT string + +func (p *UpsertCustomCourseResponse) GetCourseId() (v string) { + if !p.IsSetCourseId() { + return UpsertCustomCourseResponse_CourseId_DEFAULT + } + return *p.CourseId +} +func (p *UpsertCustomCourseResponse) SetBase(val *model.BaseResp) { + p.Base = val +} +func (p *UpsertCustomCourseResponse) SetCourseId(val *string) { + p.CourseId = val +} + +func (p *UpsertCustomCourseResponse) IsSetBase() bool { + return p.Base != nil +} + +func (p *UpsertCustomCourseResponse) IsSetCourseId() bool { + return p.CourseId != nil +} + +func (p *UpsertCustomCourseResponse) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("UpsertCustomCourseResponse(%+v)", *p) +} + +type DeleteCustomCourseRequest struct { + Term string `thrift:"term,1,required" frugal:"1,required,string" json:"term"` + CourseId string `thrift:"courseId,2,required" frugal:"2,required,string" json:"courseId"` +} + +func NewDeleteCustomCourseRequest() *DeleteCustomCourseRequest { + return &DeleteCustomCourseRequest{} +} + +func (p *DeleteCustomCourseRequest) InitDefault() { +} + +func (p *DeleteCustomCourseRequest) GetTerm() (v string) { + return p.Term +} + +func (p *DeleteCustomCourseRequest) GetCourseId() (v string) { + return p.CourseId +} +func (p *DeleteCustomCourseRequest) SetTerm(val string) { + p.Term = val +} +func (p *DeleteCustomCourseRequest) SetCourseId(val string) { + p.CourseId = val +} + +func (p *DeleteCustomCourseRequest) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("DeleteCustomCourseRequest(%+v)", *p) +} + +type DeleteCustomCourseResponse struct { + Base *model.BaseResp `thrift:"base,1,required" frugal:"1,required,model.BaseResp" json:"base"` +} + +func NewDeleteCustomCourseResponse() *DeleteCustomCourseResponse { + return &DeleteCustomCourseResponse{} +} + +func (p *DeleteCustomCourseResponse) InitDefault() { +} + +var DeleteCustomCourseResponse_Base_DEFAULT *model.BaseResp + +func (p *DeleteCustomCourseResponse) GetBase() (v *model.BaseResp) { + if !p.IsSetBase() { + return DeleteCustomCourseResponse_Base_DEFAULT + } + return p.Base +} +func (p *DeleteCustomCourseResponse) SetBase(val *model.BaseResp) { + p.Base = val +} + +func (p *DeleteCustomCourseResponse) IsSetBase() bool { + return p.Base != nil +} + +func (p *DeleteCustomCourseResponse) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("DeleteCustomCourseResponse(%+v)", *p) +} + type CourseService interface { GetCourseList(ctx context.Context, req *CourseListRequest) (r *CourseListResponse, err error) @@ -577,4 +894,8 @@ type CourseService interface { GetAutoAdjustCourseList(ctx context.Context, req *GetAutoAdjustCourseListRequest) (r *GetAutoAdjustCourseListResponse, err error) UpdateAdjustCourse(ctx context.Context, req *UpdateAdjustCourseRequest) (r *UpdateAdjustCourseResponse, err error) + + UpsertCustomCourse(ctx context.Context, req *UpsertCustomCourseRequest) (r *UpsertCustomCourseResponse, err error) + + DeleteCustomCourse(ctx context.Context, req *DeleteCustomCourseRequest) (r *DeleteCustomCourseResponse, err error) } diff --git a/kitex_gen/course/courseservice/client.go b/kitex_gen/course/courseservice/client.go index 05922a488..099f98121 100644 --- a/kitex_gen/course/courseservice/client.go +++ b/kitex_gen/course/courseservice/client.go @@ -14,16 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Code generated by Kitex v0.16.1. DO NOT EDIT. +// Code generated by Kitex v0.16.3. DO NOT EDIT. package courseservice import ( "context" - client "github.com/cloudwego/kitex/client" callopt "github.com/cloudwego/kitex/client/callopt" - course "github.com/west2-online/fzuhelper-server/kitex_gen/course" ) @@ -36,6 +34,8 @@ type Client interface { GetFriendCourse(ctx context.Context, req *course.GetFriendCourseRequest, callOptions ...callopt.Option) (r *course.GetFriendCourseResponse, err error) GetAutoAdjustCourseList(ctx context.Context, req *course.GetAutoAdjustCourseListRequest, callOptions ...callopt.Option) (r *course.GetAutoAdjustCourseListResponse, err error) UpdateAdjustCourse(ctx context.Context, req *course.UpdateAdjustCourseRequest, callOptions ...callopt.Option) (r *course.UpdateAdjustCourseResponse, err error) + UpsertCustomCourse(ctx context.Context, req *course.UpsertCustomCourseRequest, callOptions ...callopt.Option) (r *course.UpsertCustomCourseResponse, err error) + DeleteCustomCourse(ctx context.Context, req *course.DeleteCustomCourseRequest, callOptions ...callopt.Option) (r *course.DeleteCustomCourseResponse, err error) } // NewClient creates a client for the service defined in IDL. @@ -101,3 +101,13 @@ func (p *kCourseServiceClient) UpdateAdjustCourse(ctx context.Context, req *cour ctx = client.NewCtxWithCallOptions(ctx, callOptions) return p.kClient.UpdateAdjustCourse(ctx, req) } + +func (p *kCourseServiceClient) UpsertCustomCourse(ctx context.Context, req *course.UpsertCustomCourseRequest, callOptions ...callopt.Option) (r *course.UpsertCustomCourseResponse, err error) { + ctx = client.NewCtxWithCallOptions(ctx, callOptions) + return p.kClient.UpsertCustomCourse(ctx, req) +} + +func (p *kCourseServiceClient) DeleteCustomCourse(ctx context.Context, req *course.DeleteCustomCourseRequest, callOptions ...callopt.Option) (r *course.DeleteCustomCourseResponse, err error) { + ctx = client.NewCtxWithCallOptions(ctx, callOptions) + return p.kClient.DeleteCustomCourse(ctx, req) +} diff --git a/kitex_gen/course/courseservice/courseservice.go b/kitex_gen/course/courseservice/courseservice.go index e87e34d00..a0900d25c 100644 --- a/kitex_gen/course/courseservice/courseservice.go +++ b/kitex_gen/course/courseservice/courseservice.go @@ -14,17 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Code generated by Kitex v0.16.1. DO NOT EDIT. +// Code generated by Kitex v0.16.3. DO NOT EDIT. package courseservice import ( "context" "errors" - client "github.com/cloudwego/kitex/client" kitex "github.com/cloudwego/kitex/pkg/serviceinfo" - course "github.com/west2-online/fzuhelper-server/kitex_gen/course" ) @@ -80,6 +78,20 @@ var serviceMethods = map[string]kitex.MethodInfo{ false, kitex.WithStreamingMode(kitex.StreamingNone), ), + "UpsertCustomCourse": kitex.NewMethodInfo( + upsertCustomCourseHandler, + newCourseServiceUpsertCustomCourseArgs, + newCourseServiceUpsertCustomCourseResult, + false, + kitex.WithStreamingMode(kitex.StreamingNone), + ), + "DeleteCustomCourse": kitex.NewMethodInfo( + deleteCustomCourseHandler, + newCourseServiceDeleteCustomCourseArgs, + newCourseServiceDeleteCustomCourseResult, + false, + kitex.WithStreamingMode(kitex.StreamingNone), + ), } var ( @@ -140,7 +152,7 @@ func newServiceInfo(hasStreaming bool, keepStreamingMethods bool, keepNonStreami HandlerType: handlerType, Methods: methods, PayloadCodec: kitex.Thrift, - KiteXGenVersion: "v0.16.1", + KiteXGenVersion: "v0.16.3", Extra: extra, } return svcInfo @@ -272,6 +284,42 @@ func newCourseServiceUpdateAdjustCourseResult() interface{} { return course.NewCourseServiceUpdateAdjustCourseResult() } +func upsertCustomCourseHandler(ctx context.Context, handler interface{}, arg, result interface{}) error { + realArg := arg.(*course.CourseServiceUpsertCustomCourseArgs) + realResult := result.(*course.CourseServiceUpsertCustomCourseResult) + success, err := handler.(course.CourseService).UpsertCustomCourse(ctx, realArg.Req) + if err != nil { + return err + } + realResult.Success = success + return nil +} +func newCourseServiceUpsertCustomCourseArgs() interface{} { + return course.NewCourseServiceUpsertCustomCourseArgs() +} + +func newCourseServiceUpsertCustomCourseResult() interface{} { + return course.NewCourseServiceUpsertCustomCourseResult() +} + +func deleteCustomCourseHandler(ctx context.Context, handler interface{}, arg, result interface{}) error { + realArg := arg.(*course.CourseServiceDeleteCustomCourseArgs) + realResult := result.(*course.CourseServiceDeleteCustomCourseResult) + success, err := handler.(course.CourseService).DeleteCustomCourse(ctx, realArg.Req) + if err != nil { + return err + } + realResult.Success = success + return nil +} +func newCourseServiceDeleteCustomCourseArgs() interface{} { + return course.NewCourseServiceDeleteCustomCourseArgs() +} + +func newCourseServiceDeleteCustomCourseResult() interface{} { + return course.NewCourseServiceDeleteCustomCourseResult() +} + type kClient struct { c client.Client } @@ -351,3 +399,23 @@ func (p *kClient) UpdateAdjustCourse(ctx context.Context, req *course.UpdateAdju } return _result.GetSuccess(), nil } + +func (p *kClient) UpsertCustomCourse(ctx context.Context, req *course.UpsertCustomCourseRequest) (r *course.UpsertCustomCourseResponse, err error) { + var _args course.CourseServiceUpsertCustomCourseArgs + _args.Req = req + var _result course.CourseServiceUpsertCustomCourseResult + if err = p.c.Call(ctx, "UpsertCustomCourse", &_args, &_result); err != nil { + return + } + return _result.GetSuccess(), nil +} + +func (p *kClient) DeleteCustomCourse(ctx context.Context, req *course.DeleteCustomCourseRequest) (r *course.DeleteCustomCourseResponse, err error) { + var _args course.CourseServiceDeleteCustomCourseArgs + _args.Req = req + var _result course.CourseServiceDeleteCustomCourseResult + if err = p.c.Call(ctx, "DeleteCustomCourse", &_args, &_result); err != nil { + return + } + return _result.GetSuccess(), nil +} diff --git a/kitex_gen/course/k-course.go b/kitex_gen/course/k-course.go index 181021d70..31f182b40 100644 --- a/kitex_gen/course/k-course.go +++ b/kitex_gen/course/k-course.go @@ -1,20 +1,4 @@ -/* -Copyright 2024 The west2-online Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Code generated by Kitex v0.16.1. DO NOT EDIT. +// Code generated by Kitex v0.16.3. DO NOT EDIT. package course @@ -573,3 +557,155 @@ func (p *CourseServiceUpdateAdjustCourseResult) String() string { func (p *CourseServiceUpdateAdjustCourseResult) GetResult() interface{} { return p.Success } + +type CourseServiceUpsertCustomCourseArgs struct { + Req *UpsertCustomCourseRequest `thrift:"req,1" frugal:"1,default,UpsertCustomCourseRequest" json:"req"` +} + +func NewCourseServiceUpsertCustomCourseArgs() *CourseServiceUpsertCustomCourseArgs { + return &CourseServiceUpsertCustomCourseArgs{} +} + +func (p *CourseServiceUpsertCustomCourseArgs) InitDefault() { +} + +var CourseServiceUpsertCustomCourseArgs_Req_DEFAULT *UpsertCustomCourseRequest + +func (p *CourseServiceUpsertCustomCourseArgs) GetReq() (v *UpsertCustomCourseRequest) { + if !p.IsSetReq() { + return CourseServiceUpsertCustomCourseArgs_Req_DEFAULT + } + return p.Req +} +func (p *CourseServiceUpsertCustomCourseArgs) SetReq(val *UpsertCustomCourseRequest) { + p.Req = val +} + +func (p *CourseServiceUpsertCustomCourseArgs) IsSetReq() bool { + return p.Req != nil +} + +func (p *CourseServiceUpsertCustomCourseArgs) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CourseServiceUpsertCustomCourseArgs(%+v)", *p) +} + +func (p *CourseServiceUpsertCustomCourseArgs) GetFirstArgument() interface{} { + return p.Req +} + +type CourseServiceUpsertCustomCourseResult struct { + Success *UpsertCustomCourseResponse `thrift:"success,0,optional" frugal:"0,optional,UpsertCustomCourseResponse" json:"success,omitempty"` +} + +func NewCourseServiceUpsertCustomCourseResult() *CourseServiceUpsertCustomCourseResult { + return &CourseServiceUpsertCustomCourseResult{} +} + +func (p *CourseServiceUpsertCustomCourseResult) InitDefault() { +} + +var CourseServiceUpsertCustomCourseResult_Success_DEFAULT *UpsertCustomCourseResponse + +func (p *CourseServiceUpsertCustomCourseResult) GetSuccess() (v *UpsertCustomCourseResponse) { + if !p.IsSetSuccess() { + return CourseServiceUpsertCustomCourseResult_Success_DEFAULT + } + return p.Success +} +func (p *CourseServiceUpsertCustomCourseResult) SetSuccess(x interface{}) { + p.Success = x.(*UpsertCustomCourseResponse) +} + +func (p *CourseServiceUpsertCustomCourseResult) IsSetSuccess() bool { + return p.Success != nil +} + +func (p *CourseServiceUpsertCustomCourseResult) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CourseServiceUpsertCustomCourseResult(%+v)", *p) +} + +func (p *CourseServiceUpsertCustomCourseResult) GetResult() interface{} { + return p.Success +} + +type CourseServiceDeleteCustomCourseArgs struct { + Req *DeleteCustomCourseRequest `thrift:"req,1" frugal:"1,default,DeleteCustomCourseRequest" json:"req"` +} + +func NewCourseServiceDeleteCustomCourseArgs() *CourseServiceDeleteCustomCourseArgs { + return &CourseServiceDeleteCustomCourseArgs{} +} + +func (p *CourseServiceDeleteCustomCourseArgs) InitDefault() { +} + +var CourseServiceDeleteCustomCourseArgs_Req_DEFAULT *DeleteCustomCourseRequest + +func (p *CourseServiceDeleteCustomCourseArgs) GetReq() (v *DeleteCustomCourseRequest) { + if !p.IsSetReq() { + return CourseServiceDeleteCustomCourseArgs_Req_DEFAULT + } + return p.Req +} +func (p *CourseServiceDeleteCustomCourseArgs) SetReq(val *DeleteCustomCourseRequest) { + p.Req = val +} + +func (p *CourseServiceDeleteCustomCourseArgs) IsSetReq() bool { + return p.Req != nil +} + +func (p *CourseServiceDeleteCustomCourseArgs) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CourseServiceDeleteCustomCourseArgs(%+v)", *p) +} + +func (p *CourseServiceDeleteCustomCourseArgs) GetFirstArgument() interface{} { + return p.Req +} + +type CourseServiceDeleteCustomCourseResult struct { + Success *DeleteCustomCourseResponse `thrift:"success,0,optional" frugal:"0,optional,DeleteCustomCourseResponse" json:"success,omitempty"` +} + +func NewCourseServiceDeleteCustomCourseResult() *CourseServiceDeleteCustomCourseResult { + return &CourseServiceDeleteCustomCourseResult{} +} + +func (p *CourseServiceDeleteCustomCourseResult) InitDefault() { +} + +var CourseServiceDeleteCustomCourseResult_Success_DEFAULT *DeleteCustomCourseResponse + +func (p *CourseServiceDeleteCustomCourseResult) GetSuccess() (v *DeleteCustomCourseResponse) { + if !p.IsSetSuccess() { + return CourseServiceDeleteCustomCourseResult_Success_DEFAULT + } + return p.Success +} +func (p *CourseServiceDeleteCustomCourseResult) SetSuccess(x interface{}) { + p.Success = x.(*DeleteCustomCourseResponse) +} + +func (p *CourseServiceDeleteCustomCourseResult) IsSetSuccess() bool { + return p.Success != nil +} + +func (p *CourseServiceDeleteCustomCourseResult) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("CourseServiceDeleteCustomCourseResult(%+v)", *p) +} + +func (p *CourseServiceDeleteCustomCourseResult) GetResult() interface{} { + return p.Success +} diff --git a/pkg/db/course/create_custom_course.go b/pkg/db/course/create_custom_course.go new file mode 100644 index 000000000..85105bacf --- /dev/null +++ b/pkg/db/course/create_custom_course.go @@ -0,0 +1,55 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/pkg/db/model" +) + +// CreateCustomCourse 创建自定义课程 +func (c *DBCourse) CreateCustomCourse(ctx context.Context, course *model.UserCustomCourse) error { + return c.client.WithContext(ctx).Create(course).Error +} + +// CheckDuplicateCustomCourse 检查是否存在重复的自定义课程(用于多端去重) +// 当 name, location, start_class, end_class, start_week, end_week, weekday, is_single, is_double 完全一致时判定为重复 +// 返回值:(是否重复, 重复课程的CourseId, 错误) +func (c *DBCourse) CheckDuplicateCustomCourse(ctx context.Context, stuId, term string, + name, location string, startClass, endClass, startWeek, endWeek, weekday int, + isSingle, isDouble bool, +) (bool, string, error) { + var existing model.UserCustomCourse + err := c.client.WithContext(ctx).Model(&model.UserCustomCourse{}). + Select("course_id"). + Where("stu_id = ? AND term = ? AND deleted_at IS NULL", stuId, term). + Where("name = ? AND location = ? AND start_class = ? AND end_class = ? AND start_week = ? AND end_week = ? AND weekday = ?", + name, location, startClass, endClass, startWeek, endWeek, weekday). + Where("is_single = ? AND is_double = ?", isSingle, isDouble). + First(&existing).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, "", nil + } + return false, "", err + } + return true, existing.CourseId, nil +} diff --git a/pkg/db/course/create_custom_course_test.go b/pkg/db/course/create_custom_course_test.go new file mode 100644 index 000000000..b28602d7c --- /dev/null +++ b/pkg/db/course/create_custom_course_test.go @@ -0,0 +1,182 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + "fmt" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/pkg/db/model" + "github.com/west2-online/fzuhelper-server/pkg/utils" +) + +func TestDBCourse_CreateCustomCourse(t *testing.T) { + type testCase struct { + name string + mockError error + input *model.UserCustomCourse + expectingError bool + } + + inputCourse := &model.UserCustomCourse{ + StuId: "222200311", + Term: "202401", + CourseId: "uuid-1", + Name: "自习", + Location: "图书馆", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + } + + testCases := []testCase{ + { + name: "CreateCustomCourse_Success", + mockError: nil, + input: inputCourse, + expectingError: false, + }, + { + name: "CreateCustomCourse_DBError", + mockError: fmt.Errorf("db error"), + input: inputCourse, + expectingError: true, + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockGormDB := new(gorm.DB) + mockSnowflake := new(utils.Snowflake) + mockDBCourse := NewDBCourse(mockGormDB, mockSnowflake) + + mockey.Mock((*gorm.DB).WithContext).To(func(ctx context.Context) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Create).To(func(value interface{}) *gorm.DB { + if tc.mockError != nil { + mockGormDB.Error = tc.mockError + return mockGormDB + } + return mockGormDB + }).Build() + + err := mockDBCourse.CreateCustomCourse(context.Background(), tc.input) + + if tc.expectingError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDBCourse_CheckDuplicateCustomCourse(t *testing.T) { + type testCase struct { + name string + mockFirstError error + expectingError bool + expectedResult bool + expectedCourseId string + } + + const existingCourseId = "existing-uuid-123" + + testCases := []testCase{ + { + name: "CheckDuplicateCustomCourse_Duplicate", + mockFirstError: nil, + expectingError: false, + expectedResult: true, + expectedCourseId: existingCourseId, + }, + { + name: "CheckDuplicateCustomCourse_NotDuplicate", + mockFirstError: gorm.ErrRecordNotFound, + expectingError: false, + expectedResult: false, + expectedCourseId: "", + }, + { + name: "CheckDuplicateCustomCourse_DBError", + mockFirstError: fmt.Errorf("db error"), + expectingError: true, + expectedResult: false, + expectedCourseId: "", + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockGormDB := new(gorm.DB) + mockSnowflake := new(utils.Snowflake) + mockDBCourse := NewDBCourse(mockGormDB, mockSnowflake) + + mockey.Mock((*gorm.DB).WithContext).To(func(ctx context.Context) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Model).To(func(value interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Select).To(func(query interface{}, args ...interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Where).To(func(query interface{}, args ...interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).First).To(func(dest interface{}, conds ...interface{}) *gorm.DB { + if tc.mockFirstError != nil { + mockGormDB.Error = tc.mockFirstError + return mockGormDB + } + // 填充 CourseId + if v, ok := dest.(*model.UserCustomCourse); ok { + v.CourseId = existingCourseId + } + return mockGormDB + }).Build() + + result, courseId, err := mockDBCourse.CheckDuplicateCustomCourse( + context.Background(), + "222200311", "202401", + "自习", "图书馆", + 1, 2, 1, 16, 1, + false, false, + ) + + if tc.expectingError { + assert.Error(t, err) + assert.False(t, result) + assert.Empty(t, courseId) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + assert.Equal(t, tc.expectedCourseId, courseId) + } + }) + } +} diff --git a/pkg/db/course/delete_custom_course.go b/pkg/db/course/delete_custom_course.go new file mode 100644 index 000000000..8979c918a --- /dev/null +++ b/pkg/db/course/delete_custom_course.go @@ -0,0 +1,31 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + + "github.com/west2-online/fzuhelper-server/pkg/db/model" +) + +// DeleteCustomCourse 删除自定义课程(软删除),返回受影响的行数 +func (c *DBCourse) DeleteCustomCourse(ctx context.Context, stuId, term, courseId string) (int64, error) { + result := c.client.WithContext(ctx). + Where("stu_id = ? AND term = ? AND course_id = ?", stuId, term, courseId). + Delete(&model.UserCustomCourse{}) + return result.RowsAffected, result.Error +} diff --git a/pkg/db/course/delete_custom_course_test.go b/pkg/db/course/delete_custom_course_test.go new file mode 100644 index 000000000..f670e889b --- /dev/null +++ b/pkg/db/course/delete_custom_course_test.go @@ -0,0 +1,108 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + "fmt" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/pkg/utils" +) + +func TestDBCourse_DeleteCustomCourse(t *testing.T) { + type testCase struct { + name string + mockError error + mockRowsAffected int64 + stuId string + term string + courseId string + expectingError bool + expectedRows int64 + } + + testCases := []testCase{ + { + name: "DeleteCustomCourse_Success", + mockError: nil, + mockRowsAffected: 1, + stuId: "222200311", + term: "202401", + courseId: "uuid-1", + expectingError: false, + expectedRows: 1, + }, + { + name: "DeleteCustomCourse_NotFound", + mockError: nil, + mockRowsAffected: 0, + stuId: "222200311", + term: "202401", + courseId: "not-exist", + expectingError: false, + expectedRows: 0, + }, + { + name: "DeleteCustomCourse_DBError", + mockError: fmt.Errorf("db error"), + mockRowsAffected: 0, + stuId: "222200311", + term: "202401", + courseId: "uuid-1", + expectingError: true, + expectedRows: 0, + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockGormDB := new(gorm.DB) + mockSnowflake := new(utils.Snowflake) + mockDBCourse := NewDBCourse(mockGormDB, mockSnowflake) + + mockey.Mock((*gorm.DB).WithContext).To(func(ctx context.Context) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Where).To(func(query interface{}, args ...interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Delete).To(func(value interface{}, conds ...interface{}) *gorm.DB { + mockGormDB.RowsAffected = tc.mockRowsAffected + if tc.mockError != nil { + mockGormDB.Error = tc.mockError + return mockGormDB + } + return mockGormDB + }).Build() + + rows, err := mockDBCourse.DeleteCustomCourse(context.Background(), tc.stuId, tc.term, tc.courseId) + + if tc.expectingError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.expectedRows, rows) + }) + } +} diff --git a/pkg/db/course/get_custom_course.go b/pkg/db/course/get_custom_course.go new file mode 100644 index 000000000..6b06b826f --- /dev/null +++ b/pkg/db/course/get_custom_course.go @@ -0,0 +1,44 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + + "github.com/west2-online/fzuhelper-server/pkg/db/model" +) + +// GetCustomCourses 获取用户指定学期的自定义课程列表 +func (c *DBCourse) GetCustomCourses(ctx context.Context, stuId, term string) ([]*model.UserCustomCourse, error) { + var courses []*model.UserCustomCourse + err := c.client.WithContext(ctx). + Where("stu_id = ? AND term = ? AND deleted_at IS NULL", stuId, term). + Find(&courses).Error + return courses, err +} + +// GetCustomCourseByID 根据 courseId 获取单个自定义课程 +func (c *DBCourse) GetCustomCourseByID(ctx context.Context, stuId, term, courseId string) (*model.UserCustomCourse, error) { + var course model.UserCustomCourse + err := c.client.WithContext(ctx). + Where("stu_id = ? AND term = ? AND course_id = ? AND deleted_at IS NULL", stuId, term, courseId). + First(&course).Error + if err != nil { + return nil, err + } + return &course, nil +} diff --git a/pkg/db/course/get_custom_course_test.go b/pkg/db/course/get_custom_course_test.go new file mode 100644 index 000000000..b078494e8 --- /dev/null +++ b/pkg/db/course/get_custom_course_test.go @@ -0,0 +1,224 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + "fmt" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/pkg/db/model" + "github.com/west2-online/fzuhelper-server/pkg/utils" +) + +func TestDBCourse_GetCustomCourses(t *testing.T) { + type testCase struct { + name string + mockError error + stuId string + term string + expectedResult []*model.UserCustomCourse + expectingError bool + } + + expectedCourses := []*model.UserCustomCourse{ + { + StuId: "222200311", + Term: "202401", + CourseId: "uuid-1", + Name: "自习", + Location: "图书馆", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + }, + { + StuId: "222200311", + Term: "202401", + CourseId: "uuid-2", + Name: "开会", + Location: "会议室", + StartClass: 3, + EndClass: 4, + StartWeek: 1, + EndWeek: 16, + Weekday: 3, + }, + } + + testCases := []testCase{ + { + name: "GetCustomCourses_Success", + mockError: nil, + stuId: "222200311", + term: "202401", + expectedResult: expectedCourses, + expectingError: false, + }, + { + name: "GetCustomCourses_Empty", + mockError: nil, + stuId: "222200311", + term: "202402", + expectedResult: []*model.UserCustomCourse{}, + expectingError: false, + }, + { + name: "GetCustomCourses_DBError", + mockError: fmt.Errorf("db error"), + stuId: "222200311", + term: "202401", + expectedResult: nil, + expectingError: true, + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockGormDB := new(gorm.DB) + mockSnowflake := new(utils.Snowflake) + mockDBCourse := NewDBCourse(mockGormDB, mockSnowflake) + + mockey.Mock((*gorm.DB).WithContext).To(func(ctx context.Context) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Where).To(func(query interface{}, args ...interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Find).To(func(dest interface{}, conds ...interface{}) *gorm.DB { + if tc.mockError != nil { + mockGormDB.Error = tc.mockError + return mockGormDB + } + courses, ok := dest.(*[]*model.UserCustomCourse) + if ok && tc.expectedResult != nil { + *courses = tc.expectedResult + } + return mockGormDB + }).Build() + + result, err := mockDBCourse.GetCustomCourses(context.Background(), tc.stuId, tc.term) + + if tc.expectingError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + } + }) + } +} + +func TestDBCourse_GetCustomCourseByID(t *testing.T) { + type testCase struct { + name string + mockError error + stuId string + term string + courseId string + expectedResult *model.UserCustomCourse + expectingError bool + } + + expectedCourse := &model.UserCustomCourse{ + StuId: "222200311", + Term: "202401", + CourseId: "uuid-1", + Name: "自习", + Location: "图书馆", + StartClass: 1, + EndClass: 2, + StartWeek: 1, + EndWeek: 16, + Weekday: 1, + } + + testCases := []testCase{ + { + name: "GetCustomCourseByID_Success", + mockError: nil, + stuId: "222200311", + term: "202401", + courseId: "uuid-1", + expectedResult: expectedCourse, + expectingError: false, + }, + { + name: "GetCustomCourseByID_NotFound", + mockError: gorm.ErrRecordNotFound, + stuId: "222200311", + term: "202401", + courseId: "not-exist", + expectedResult: nil, + expectingError: true, + }, + { + name: "GetCustomCourseByID_DBError", + mockError: fmt.Errorf("db error"), + stuId: "222200311", + term: "202401", + courseId: "uuid-1", + expectedResult: nil, + expectingError: true, + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockGormDB := new(gorm.DB) + mockSnowflake := new(utils.Snowflake) + mockDBCourse := NewDBCourse(mockGormDB, mockSnowflake) + + mockey.Mock((*gorm.DB).WithContext).To(func(ctx context.Context) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Where).To(func(query interface{}, args ...interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).First).To(func(dest interface{}, conds ...interface{}) *gorm.DB { + if tc.mockError != nil { + mockGormDB.Error = tc.mockError + return mockGormDB + } + course, ok := dest.(*model.UserCustomCourse) + if ok && tc.expectedResult != nil { + *course = *tc.expectedResult + } + return mockGormDB + }).Build() + + result, err := mockDBCourse.GetCustomCourseByID(context.Background(), tc.stuId, tc.term, tc.courseId) + + if tc.expectingError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + } + }) + } +} diff --git a/pkg/db/course/update_custom_course.go b/pkg/db/course/update_custom_course.go new file mode 100644 index 000000000..7938ae0f7 --- /dev/null +++ b/pkg/db/course/update_custom_course.go @@ -0,0 +1,32 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + + "github.com/west2-online/fzuhelper-server/pkg/db/model" +) + +// UpdateCustomCourse 更新自定义课程,返回受影响的行数 +func (c *DBCourse) UpdateCustomCourse(ctx context.Context, stuId, term, courseId string, updates map[string]interface{}) (int64, error) { + result := c.client.WithContext(ctx). + Model(&model.UserCustomCourse{}). + Where("stu_id = ? AND term = ? AND course_id = ?", stuId, term, courseId). + Updates(updates) + return result.RowsAffected, result.Error +} diff --git a/pkg/db/course/update_custom_course_test.go b/pkg/db/course/update_custom_course_test.go new file mode 100644 index 000000000..7b2333628 --- /dev/null +++ b/pkg/db/course/update_custom_course_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + "fmt" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/pkg/utils" +) + +func TestDBCourse_UpdateCustomCourse(t *testing.T) { + type testCase struct { + name string + mockError error + mockRowsAffected int64 + stuId string + term string + courseId string + updates map[string]interface{} + expectingError bool + expectedRows int64 + } + + testCases := []testCase{ + { + name: "UpdateCustomCourse_Success", + mockError: nil, + mockRowsAffected: 1, + stuId: "222200311", + term: "202401", + courseId: "uuid-1", + updates: map[string]interface{}{ + "name": "自习(更新)", + "location": "图书馆3楼", + "start_class": 3, + "end_class": 4, + }, + expectingError: false, + expectedRows: 1, + }, + { + name: "UpdateCustomCourse_NotFound", + mockError: nil, + mockRowsAffected: 0, + stuId: "222200311", + term: "202401", + courseId: "not-exist", + updates: map[string]interface{}{ + "name": "自习(更新)", + }, + expectingError: false, + expectedRows: 0, + }, + { + name: "UpdateCustomCourse_DBError", + mockError: fmt.Errorf("db error"), + mockRowsAffected: 0, + stuId: "222200311", + term: "202401", + courseId: "uuid-1", + updates: map[string]interface{}{ + "name": "自习(更新)", + }, + expectingError: true, + expectedRows: 0, + }, + } + + defer mockey.UnPatchAll() + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockGormDB := new(gorm.DB) + mockSnowflake := new(utils.Snowflake) + mockDBCourse := NewDBCourse(mockGormDB, mockSnowflake) + + mockey.Mock((*gorm.DB).WithContext).To(func(ctx context.Context) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Model).To(func(value interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Where).To(func(query interface{}, args ...interface{}) *gorm.DB { + return mockGormDB + }).Build() + mockey.Mock((*gorm.DB).Updates).To(func(values interface{}) *gorm.DB { + mockGormDB.RowsAffected = tc.mockRowsAffected + if tc.mockError != nil { + mockGormDB.Error = tc.mockError + return mockGormDB + } + return mockGormDB + }).Build() + + rows, err := mockDBCourse.UpdateCustomCourse(context.Background(), tc.stuId, tc.term, tc.courseId, tc.updates) + + if tc.expectingError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.expectedRows, rows) + }) + } +} diff --git a/pkg/db/model/course.go b/pkg/db/model/course.go index 7c3953a0e..016678f1a 100644 --- a/pkg/db/model/course.go +++ b/pkg/db/model/course.go @@ -68,3 +68,29 @@ type AutoAdjustCourse struct { UpdatedAt time.Time DeletedAt gorm.DeletedAt `sql:"index"` } + +type UserCustomCourse struct { + StuId string `gorm:"index:idx_stu;uniqueIndex:uk_stu_term_course"` + Term string `gorm:"index:idx_term;uniqueIndex:uk_stu_term_course"` + CourseId string `gorm:"primaryKey;uniqueIndex:uk_stu_term_course"` + Name string + Teacher string + Location string + StartClass int + EndClass int + StartWeek int + EndWeek int + Weekday int + IsSingle bool + IsDouble bool + Color string + Remark string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index:idx_deleted" sql:"index"` +} + +// TableName 指定表名 +func (UserCustomCourse) TableName() string { + return "user_custom_courses" +} diff --git a/pkg/errno/default.go b/pkg/errno/default.go index ce71652f2..59a8003e9 100644 --- a/pkg/errno/default.go +++ b/pkg/errno/default.go @@ -48,4 +48,7 @@ var ( // jwch EvaluationNotFoundError = NewErrNo(BizJwchEvaluationNotFoundCode, "请先对任课教师进行评价") // jwch 未进行评测 + + // course + CustomCourseNotFoundError = NewErrNo(BizErrorCode, "自定义课程不存在") )