后端: 1.阶段 5 course 服务边界落地 - 新增 cmd/course 独立进程入口,落地 services/course dao/rpc/sv - 新增 gateway/client/course、shared/contracts/course 和 shared/ports course port - 将 /api/v1/course/* HTTP 门面切到 course zrpc,gateway 只保留鉴权、限流、幂等、文件读取和响应透传 - 保留 course 迁移期直写 schedule_events / schedules 权限,维持课程导入两个表同事务写入语义 - 为 course parse-image 补 bytes RPC 契约和 gRPC 消息大小配置,兼容课表图片上传 - 补充 course.rpc 示例配置与阶段 5 文档基线、切流点、残留依赖和 smoke 记录
112 lines
2.8 KiB
Go
112 lines
2.8 KiB
Go
package course
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/respond"
|
||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||
"google.golang.org/grpc/codes"
|
||
"google.golang.org/grpc/status"
|
||
)
|
||
|
||
// CourseImportConflictError 表示课程导入和已有非课程日程冲突。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只在 gateway 边缘层用于恢复旧 HTTP 409 + conflicts 响应;
|
||
// 2. 不承载冲突计算逻辑,冲突详情由 course 服务生成;
|
||
// 3. ConflictsJSON 返回原始 JSON,避免 gateway 复制 schedule 冲突 DTO。
|
||
type CourseImportConflictError struct {
|
||
conflicts json.RawMessage
|
||
}
|
||
|
||
func (e CourseImportConflictError) Error() string {
|
||
return respond.ScheduleConflict.Info
|
||
}
|
||
|
||
func (e CourseImportConflictError) ConflictsJSON() json.RawMessage {
|
||
if len(e.conflicts) == 0 {
|
||
return json.RawMessage("[]")
|
||
}
|
||
return e.conflicts
|
||
}
|
||
|
||
// responseFromRPCError 负责把 course 的 gRPC 错误反解回项目内错误。
|
||
func responseFromRPCError(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
st, ok := status.FromError(err)
|
||
if !ok {
|
||
return wrapRPCError(err)
|
||
}
|
||
if resp, ok := responseFromStatus(st); ok {
|
||
return resp
|
||
}
|
||
|
||
switch st.Code() {
|
||
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
|
||
msg := strings.TrimSpace(st.Message())
|
||
if msg == "" {
|
||
msg = "course zrpc service internal error"
|
||
}
|
||
return wrapRPCError(errors.New(msg))
|
||
}
|
||
|
||
msg := strings.TrimSpace(st.Message())
|
||
if msg == "" {
|
||
msg = "course zrpc service rejected request"
|
||
}
|
||
return respond.Response{Status: grpcCodeToRespondStatus(st.Code()), Info: msg}
|
||
}
|
||
|
||
func responseFromStatus(st *status.Status) (respond.Response, bool) {
|
||
if st == nil {
|
||
return respond.Response{}, false
|
||
}
|
||
for _, detail := range st.Details() {
|
||
info, ok := detail.(*errdetails.ErrorInfo)
|
||
if !ok {
|
||
continue
|
||
}
|
||
statusValue := strings.TrimSpace(info.Reason)
|
||
if statusValue == "" {
|
||
statusValue = grpcCodeToRespondStatus(st.Code())
|
||
}
|
||
message := strings.TrimSpace(st.Message())
|
||
if message == "" && info.Metadata != nil {
|
||
message = strings.TrimSpace(info.Metadata["info"])
|
||
}
|
||
if message == "" {
|
||
message = statusValue
|
||
}
|
||
return respond.Response{Status: statusValue, Info: message}, true
|
||
}
|
||
return respond.Response{}, false
|
||
}
|
||
|
||
func grpcCodeToRespondStatus(code codes.Code) string {
|
||
switch code {
|
||
case codes.Unauthenticated:
|
||
return respond.ErrUnauthorized.Status
|
||
case codes.PermissionDenied:
|
||
return respond.ErrUnauthorized.Status
|
||
case codes.InvalidArgument:
|
||
return respond.WrongParamType.Status
|
||
case codes.Internal, codes.Unknown, codes.DataLoss:
|
||
return "500"
|
||
default:
|
||
return "400"
|
||
}
|
||
}
|
||
|
||
func wrapRPCError(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("调用 course zrpc 服务失败: %w", err)
|
||
}
|