feat(t226): add Go ERP session login

This commit is contained in:
QiuSW
2026-07-29 09:51:41 +08:00
parent c2f340d067
commit a5a61c05d0
23 changed files with 1633 additions and 33 deletions
@@ -77,6 +77,12 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
}
if _, ok := h.service.(ERPConnectionService); ok {
routes.GET("/erp", SecurityHeaders(), h.ERPConnection)
routes.POST("/erp/captcha", SecurityHeaders(), h.RequestERPCaptcha)
routes.GET("/erp/captcha/:ticket", SecurityHeaders(), h.ERPCaptchaImage)
routes.POST("/erp/login", SecurityHeaders(), h.LoginERP)
}
if _, ok := h.service.(ProcurementService); ok {
routes.POST(
"/freight/items/:id/procurement-request",
@@ -96,6 +102,195 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
}
}
func (h *Handler) ERPConnection(ctx *gin.Context) {
service := h.service.(ERPConnectionService)
status, err := service.ERPConnectionStatus(ctx.Request.Context())
if err != nil {
h.renderERPConnection(
ctx,
http.StatusServiceUnavailable,
ERPConnectionStatus{},
"ERP 连接状态暂时无法读取。",
"",
)
return
}
h.renderERPConnection(
ctx,
http.StatusOK,
status,
"",
erpConnectionNotice(ctx.Query("notice")),
)
}
func (h *Handler) RequestERPCaptcha(ctx *gin.Context) {
ctx.Request.Body = http.MaxBytesReader(
ctx.Writer,
ctx.Request.Body,
maxLoginFormBytes,
)
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请刷新 ERP 连接页面后重试。")
return
}
service := h.service.(ERPConnectionService)
status, err := service.RequestERPCaptcha(ctx.Request.Context())
if err != nil {
h.renderERPConnection(
ctx,
erpConnectionErrorStatus(err),
status,
erpConnectionErrorMessage(err),
"",
)
return
}
ctx.Redirect(http.StatusSeeOther, "/erp?notice=captcha-ready")
}
func (h *Handler) ERPCaptchaImage(ctx *gin.Context) {
ticket := strings.TrimSpace(ctx.Param("ticket"))
if !validToken(ticket) {
ctx.Status(http.StatusNotFound)
return
}
service := h.service.(ERPConnectionService)
image, err := service.OpenERPCaptcha(ctx.Request.Context(), ticket)
if err != nil || !strings.HasPrefix(image.ContentType, "image/") ||
len(image.Content) == 0 {
ctx.Status(http.StatusNotFound)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.Header("Content-Type", image.ContentType)
ctx.Header("Content-Length", strconv.Itoa(len(image.Content)))
ctx.Header("Content-Disposition", "inline")
ctx.Data(http.StatusOK, image.ContentType, image.Content)
}
func (h *Handler) LoginERP(ctx *gin.Context) {
ctx.Request.Body = http.MaxBytesReader(
ctx.Writer,
ctx.Request.Body,
maxLoginFormBytes,
)
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请刷新 ERP 连接页面后重试。")
return
}
ticket := strings.TrimSpace(ctx.PostForm("captcha_ticket"))
code := strings.TrimSpace(ctx.PostForm("captcha_code"))
service := h.service.(ERPConnectionService)
if !validToken(ticket) || !validERPCaptchaCode(code) {
status, _ := service.ERPConnectionStatus(ctx.Request.Context())
h.renderERPConnection(
ctx,
http.StatusUnprocessableEntity,
status,
"请重新获取验证码后输入验证码。",
"",
)
return
}
status, err := service.LoginERP(ctx.Request.Context(), ERPLoginInput{
CaptchaTicket: ticket,
CaptchaCode: code,
})
if err != nil {
h.renderERPConnection(
ctx,
erpConnectionErrorStatus(err),
status,
erpConnectionErrorMessage(err),
"",
)
return
}
ctx.Redirect(http.StatusSeeOther, "/erp?notice=login-succeeded")
}
func (h *Handler) renderERPConnection(
ctx *gin.Context,
statusCode int,
status ERPConnectionStatus,
errorMessage, notice string,
) {
token, err := csrfToken(ctx)
if err != nil {
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
return
}
h.render(ctx, statusCode, "erp-connection", erpConnectionPage{
Page: pageView{
Title: "ERP 连接",
ERPCurrent: true,
CSRFToken: token,
},
Status: status,
Error: errorMessage,
Notice: notice,
})
}
func validERPCaptchaCode(value string) bool {
if value == "" || len([]byte(value)) > 64 || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if character < 32 || character == 127 {
return false
}
}
return true
}
func erpConnectionErrorStatus(err error) int {
switch {
case errors.Is(err, ErrERPNotConfigured):
return http.StatusUnprocessableEntity
case errors.Is(err, ErrERPSessionNeeded),
errors.Is(err, ErrERPCaptchaInvalid):
return http.StatusConflict
case errors.Is(err, ErrERPLoginRejected):
return http.StatusUnprocessableEntity
case errors.Is(err, ErrERPProtocol):
return http.StatusBadGateway
case errors.Is(err, ErrUnavailable):
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
func erpConnectionErrorMessage(err error) string {
switch {
case errors.Is(err, ErrERPNotConfigured):
return "ERP 服务账号尚未在后端启动环境中配置。"
case errors.Is(err, ErrERPSessionNeeded), errors.Is(err, ErrERPCaptchaInvalid):
return "验证码已失效,请重新获取后再登录。"
case errors.Is(err, ErrERPLoginRejected):
return "验证码不正确或 ERP 拒绝登录,请重新获取验证码后重试。"
case errors.Is(err, ErrERPProtocol):
return "ERP 返回格式无法确认,请稍后重试。"
case errors.Is(err, ErrUnavailable):
return "ERP 暂时不可用,请稍后重试。"
default:
return "ERP 连接操作失败,请稍后重试。"
}
}
func erpConnectionNotice(value string) string {
switch value {
case "captcha-ready":
return "验证码已获取,请人工读取并提交。"
case "login-succeeded":
return "ERP 会话已建立,可以返回货运导入。"
default:
return ""
}
}
func (h *Handler) ListFreight(ctx *gin.Context) {
service := h.service.(FreightService)
orders, err := service.ListFreightOrders(
@@ -1087,6 +1282,7 @@ type pageView struct {
TasksCurrent bool
NewCurrent bool
FreightCurrent bool
ERPCurrent bool
CSRFToken string
}
@@ -1113,6 +1309,13 @@ type freightDetailPage struct {
Notice string
}
type erpConnectionPage struct {
Page pageView
Status ERPConnectionStatus
Error string
Notice string
}
type statusOption struct {
Value string
Label string