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
@@ -11,6 +11,7 @@ import (
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/shunyunbao"
"cmroubao/backend-api/internal/transport/authcommon"
"cmroubao/backend-api/internal/usecase"
@@ -30,6 +31,7 @@ type AdminServices struct {
Authorizations *usecase.OrderAuthorizationService
Freight *usecase.FreightService
Procurement *usecase.ProcurementService
ERP *shunyunbao.SessionManager
}
func (s AdminServices) validate() error {
@@ -87,6 +89,9 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
handler.createProcurementTask,
)
}
if services.ERP != nil {
registerERPAdminAPI(routes, handler)
}
return nil
}
@@ -23,6 +23,7 @@ import (
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/shunyunbao"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
@@ -60,6 +61,100 @@ func TestCandidateDecisionDatasetResponseIncludesPersistentIdentity(t *testing.T
}
}
func TestERPAdminAPIUsesCaptchaTicketWithoutExposingCredentials(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
switch request.URL.Path {
case shunyunbao.CaptchaPath:
http.SetCookie(writer, &http.Cookie{Name: "erp", Value: "captcha", Path: "/"})
writer.Header().Set("Content-Type", "image/png")
_, _ = writer.Write([]byte("captcha-image"))
case shunyunbao.LoginPath:
if _, err := request.Cookie("erp"); err != nil {
t.Fatalf("login did not retain captcha cookie: %v", err)
}
http.SetCookie(writer, &http.Cookie{Name: "erp", Value: "login", Path: "/"})
_, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":12},"token":"private-token"}}`))
case shunyunbao.UserPath:
if cookie, err := request.Cookie("erp"); err != nil || cookie.Value != "login" {
t.Fatalf("user session cookie = %v / %v", cookie, err)
}
_, _ = writer.Write([]byte(`{"status":true,"data":{"id":12}}`))
default:
writer.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
manager, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
BaseURL: server.URL,
Username: "private-user",
Password: "private-password",
Timeout: time.Second,
AllowInsecureHTTP: true,
})
if err != nil {
t.Fatalf("NewSessionManager() error = %v", err)
}
gin.SetMode(gin.TestMode)
router := gin.New()
registerERPAdminAPI(router, &adminHandlers{services: AdminServices{ERP: manager}})
status := performERPRequest(t, router, http.MethodGet, "/api/v1/erp-session", nil, "")
if status.Code != http.StatusOK ||
strings.Contains(status.Body.String(), "private-user") ||
strings.Contains(status.Body.String(), "private-password") {
t.Fatalf("status response = %d / %s", status.Code, status.Body)
}
captcha := performERPRequest(
t,
router,
http.MethodPost,
"/api/v1/erp-session/captcha",
nil,
"",
)
if captcha.Code != http.StatusOK ||
strings.Contains(captcha.Body.String(), "private-password") {
t.Fatalf("captcha response = %d / %s", captcha.Code, captcha.Body)
}
var captchaBody map[string]any
decodeResponse(t, captcha, &captchaBody)
ticket, _ := captchaBody["captcha_ticket"].(string)
if len(ticket) != 43 || responseContainsKey(captchaBody["session"], "captcha_ticket") {
t.Fatalf("captcha response body = %#v", captchaBody)
}
image := performERPRequest(
t,
router,
http.MethodGet,
"/api/v1/erp-session/captcha/"+ticket,
nil,
"",
)
if image.Code != http.StatusOK || image.Header().Get("Cache-Control") != "no-store" ||
image.Body.String() != "captcha-image" {
t.Fatalf("captcha image = %d / %q / %s", image.Code, image.Header(), image.Body)
}
login := performERPRequest(
t,
router,
http.MethodPost,
"/api/v1/erp-session/login",
strings.NewReader(`{"captcha_ticket":"`+ticket+`","captcha_code":"1234"}`),
"application/json",
)
if login.Code != http.StatusOK ||
strings.Contains(login.Body.String(), "private-token") ||
strings.Contains(login.Body.String(), "private-password") {
t.Fatalf("login response = %d / %s", login.Code, login.Body)
}
if !strings.Contains(login.Body.String(), `"authenticated":true`) {
t.Fatalf("login does not report authenticated state: %s", login.Body)
}
}
func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
router := newAdminIntegrationRouter(t)
imageBody, imageContentType := referenceUpload(t, "asset-key-1")
@@ -1306,6 +1401,23 @@ func performAdminRequest(
return response
}
func performERPRequest(
t *testing.T,
router http.Handler,
method, target string,
body io.Reader,
contentType string,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, target, body)
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func decodeResponse(
t *testing.T,
response *httptest.ResponseRecorder,
@@ -233,7 +233,8 @@ func denyAdminSession(ctx *gin.Context) {
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
!strings.HasPrefix(next, "/tasks/") &&
next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
!strings.HasPrefix(next, "/freight/")) {
!strings.HasPrefix(next, "/freight/") &&
next != "/erp" && !strings.HasPrefix(next, "/erp?")) {
next = "/tasks"
}
ctx.Abort()
@@ -0,0 +1,166 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/shunyunbao"
"github.com/gin-gonic/gin"
)
func registerERPAdminAPI(routes gin.IRoutes, handler *adminHandlers) {
routes.GET("/api/v1/erp-session", handler.erpSessionStatus)
routes.POST("/api/v1/erp-session/captcha", handler.createERPCaptcha)
routes.GET(
"/api/v1/erp-session/captcha/:ticket",
handler.erpCaptchaContent,
)
routes.POST("/api/v1/erp-session/login", handler.loginERP)
}
func (h *adminHandlers) erpSessionStatus(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, erpSessionResponse(h.services.ERP.Status()))
}
func (h *adminHandlers) createERPCaptcha(ctx *gin.Context) {
status, err := h.services.ERP.FetchCaptcha(ctx.Request.Context())
if err != nil {
writeERPError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"session": erpSessionResponse(status),
"captcha_ticket": status.CaptchaTicket,
"captcha_url": "/api/v1/erp-session/captcha/" + status.CaptchaTicket,
})
}
func (h *adminHandlers) erpCaptchaContent(ctx *gin.Context) {
ticket := strings.TrimSpace(ctx.Param("ticket"))
if !validERPTicket(ticket) {
writeERPError(ctx, shunyunbao.ErrCaptchaTicketInvalid)
return
}
image, err := h.services.ERP.OpenCaptcha(ticket)
if err != nil || !strings.HasPrefix(image.ContentType, "image/") ||
len(image.Content) == 0 {
writeERPError(ctx, err)
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 *adminHandlers) loginERP(ctx *gin.Context) {
if !hasMediaType(ctx, "application/json") {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"UNSUPPORTED_MEDIA_TYPE",
"application/json is required",
false,
gin.H{},
)
return
}
var request struct {
CaptchaTicket string `json:"captcha_ticket"`
CaptchaCode string `json:"captcha_code"`
}
if err := decodeJSON(ctx, &request); err != nil ||
!validERPTicket(request.CaptchaTicket) ||
!validERPCaptchaCode(request.CaptchaCode) {
writePublicError(
ctx,
http.StatusBadRequest,
"ERP_LOGIN_INVALID",
"ERP login request is invalid",
false,
gin.H{},
)
return
}
status, err := h.services.ERP.Login(
ctx.Request.Context(),
request.CaptchaTicket,
request.CaptchaCode,
)
if err != nil {
writeERPError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, erpSessionResponse(status))
}
func erpSessionResponse(status shunyunbao.SessionStatus) gin.H {
return gin.H{
"configured": status.Configured,
"authenticated": status.Authenticated,
"captcha_ready": status.CaptchaReady,
}
}
func validERPTicket(value string) bool {
value = strings.TrimSpace(value)
return len(value) == 43 && utf8.ValidString(value) &&
!strings.ContainsAny(value, " \t\r\n")
}
func validERPCaptchaCode(value string) bool {
value = strings.TrimSpace(value)
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 writeERPError(ctx *gin.Context, err error) {
status := http.StatusInternalServerError
code := "ERP_INTERNAL_ERROR"
message := "ERP connection operation failed"
retryable := false
switch {
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
status = http.StatusUnprocessableEntity
code = "ERP_NOT_CONFIGURED"
message = "ERP credentials are not configured"
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
status = http.StatusConflict
code = "ERP_SESSION_REQUIRED"
message = "ERP session is required"
case errors.Is(err, shunyunbao.ErrCaptchaTicketInvalid):
status = http.StatusConflict
code = "ERP_CAPTCHA_INVALID"
message = "ERP captcha must be requested again"
case errors.Is(err, shunyunbao.ErrLoginRejected):
status = http.StatusUnprocessableEntity
code = "ERP_LOGIN_REJECTED"
message = "ERP login was rejected"
case errors.Is(err, domain.ErrFreightSourceProtocol):
status = http.StatusBadGateway
code = "ERP_RESPONSE_INVALID"
message = "ERP response is invalid"
case errors.Is(err, domain.ErrFreightSourceUnavailable):
status = http.StatusServiceUnavailable
code = "ERP_UNAVAILABLE"
message = "ERP is temporarily unavailable"
retryable = true
}
writePublicError(ctx, status, code, message, retryable, gin.H{})
}
@@ -269,7 +269,8 @@ func safeNext(value string) string {
if parsed.Path != "/tasks" &&
!strings.HasPrefix(parsed.Path, "/tasks/") &&
parsed.Path != "/freight" &&
!strings.HasPrefix(parsed.Path, "/freight/") {
!strings.HasPrefix(parsed.Path, "/freight/") &&
parsed.Path != "/erp" {
return "/tasks"
}
return parsed.String()
@@ -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
@@ -1095,6 +1095,105 @@ func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) {
}
}
func TestERPConnectionPageUsesCaptchaOnlyAndPreservesNoCredentials(t *testing.T) {
ticket := mustToken(t)
service := &fakeERPService{
fakeService: &fakeService{},
status: ERPConnectionStatus{
Configured: true,
},
image: ERPCaptchaImage{
Content: []byte("captcha-image"),
ContentType: "image/png",
},
ticket: ticket,
}
router := newTestRouter(t, service)
page := performRequest(t, router, http.MethodGet, "/erp", nil, "")
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "获取验证码") ||
strings.Contains(page.Body.String(), "private-password") {
t.Fatalf("ERP page = %d / %s", page.Code, page.Body)
}
assertSecurityHeaders(t, page)
cookie := csrfCookie(t, page)
values := url.Values{"csrf_token": {cookie.Value}}
request := httptest.NewRequest(
http.MethodPost,
"/erp/captcha",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") != "/erp?notice=captcha-ready" ||
service.captchaRequests != 1 {
t.Fatalf("captcha response/calls = %d / %q / %d", response.Code, response.Header().Get("Location"), service.captchaRequests)
}
service.status.CaptchaReady = true
service.status.CaptchaTicket = ticket
page = performRequest(t, router, http.MethodGet, "/erp", nil, "")
if page.Code != http.StatusOK ||
!strings.Contains(page.Body.String(), "/erp/captcha/"+ticket) ||
!strings.Contains(page.Body.String(), `name="captcha_code"`) ||
strings.Contains(page.Body.String(), "password") {
t.Fatalf("captcha page = %d / %s", page.Code, page.Body)
}
image := performRequest(t, router, http.MethodGet, "/erp/captcha/"+ticket, nil, "")
if image.Code != http.StatusOK || image.Header().Get("Cache-Control") != "no-store" ||
image.Body.String() != "captcha-image" {
t.Fatalf("captcha image = %d / %q / %s", image.Code, image.Header(), image.Body)
}
cookie = csrfCookie(t, page)
values = url.Values{
"csrf_token": {cookie.Value},
"captcha_ticket": {ticket},
"captcha_code": {"1234"},
}
request = httptest.NewRequest(
http.MethodPost,
"/erp/login",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") != "/erp?notice=login-succeeded" ||
service.login.CaptchaTicket != ticket || service.login.CaptchaCode != "1234" {
t.Fatalf("login response/input = %d / %q / %+v", response.Code, response.Header().Get("Location"), service.login)
}
service.status = ERPConnectionStatus{
Configured: true,
CaptchaReady: true,
CaptchaTicket: ticket,
}
service.err = ErrERPLoginRejected
page = performRequest(t, router, http.MethodGet, "/erp", nil, "")
cookie = csrfCookie(t, page)
values = url.Values{
"csrf_token": {cookie.Value},
"captcha_ticket": {ticket},
"captcha_code": {"1234"},
}
request = httptest.NewRequest(
http.MethodPost,
"/erp/login",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnprocessableEntity ||
!strings.Contains(response.Body.String(), "验证码不正确或 ERP 拒绝登录") ||
strings.Contains(response.Body.String(), "private") {
t.Fatalf("rejected login page = %d / %s", response.Code, response.Body)
}
}
func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
const itemID = "00000000-0000-4000-8000-000000000002"
service := &fakeProcurementService{
@@ -1206,6 +1305,51 @@ type fakeFreightService struct {
err error
}
type fakeERPService struct {
*fakeService
status ERPConnectionStatus
image ERPCaptchaImage
ticket string
captchaRequests int
login ERPLoginInput
err error
}
func (service *fakeERPService) ERPConnectionStatus(
context.Context,
) (ERPConnectionStatus, error) {
return service.status, service.err
}
func (service *fakeERPService) RequestERPCaptcha(
context.Context,
) (ERPConnectionStatus, error) {
service.captchaRequests++
service.status.CaptchaReady = true
service.status.CaptchaTicket = service.ticket
return service.status, service.err
}
func (service *fakeERPService) OpenERPCaptcha(
context.Context,
string,
) (ERPCaptchaImage, error) {
return service.image, service.err
}
func (service *fakeERPService) LoginERP(
_ context.Context,
input ERPLoginInput,
) (ERPConnectionStatus, error) {
service.login = input
if service.err == nil {
service.status.Authenticated = true
service.status.CaptchaReady = false
service.status.CaptchaTicket = ""
}
return service.status, service.err
}
type fakeProcurementService struct {
*fakeFreightService
createRequestInput CreateProcurementRequestInput
@@ -258,6 +258,20 @@ select {
min-height: 44px;
}
.erp-captcha-image {
display: block;
width: 180px;
height: 64px;
margin: 12px 0;
border: 1px solid var(--line-strong);
background: var(--surface);
object-fit: contain;
}
.compact-form {
margin-top: 12px;
}
a,
button,
input,
@@ -0,0 +1,56 @@
{{define "erp-connection"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page narrow-page">
<div class="title-row">
<div>
<h1>ERP 连接</h1>
<p class="subtitle">建立本次服务进程内的顺运宝会话</p>
</div>
<a class="button" href="/freight/import">返回导入</a>
</div>
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
{{if .Notice}}<div class="notice success" role="status">{{.Notice}}</div>{{end}}
{{if .Status.Authenticated}}
<section class="detail-section" aria-labelledby="erp-connected-title">
<h2 id="erp-connected-title">已连接</h2>
<p>当前后端进程持有受控 ERP 会话。重启后需要重新获取验证码登录。</p>
</section>
{{else if not .Status.Configured}}
<section class="detail-section" aria-labelledby="erp-config-title">
<h2 id="erp-config-title">尚未配置</h2>
<p>请在后端启动环境中配置顺运宝账号和密码后重启服务。</p>
</section>
{{else if .Status.CaptchaReady}}
<section class="detail-section" aria-labelledby="erp-captcha-title">
<h2 id="erp-captcha-title">输入验证码</h2>
<img class="erp-captcha-image" src="/erp/captcha/{{pathPart .Status.CaptchaTicket}}"
alt="ERP 验证码" width="180" height="64">
<form class="form-panel compact-form" method="post" action="/erp/login" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="captcha_ticket" value="{{.Status.CaptchaTicket}}">
<div class="field">
<label for="captcha-code">验证码</label>
<input id="captcha-code" name="captcha_code" maxlength="64" autocomplete="one-time-code" required>
</div>
<button class="button primary" type="submit" data-loading-label="正在登录…">登录 ERP</button>
</form>
</section>
{{else}}
<form class="form-panel" method="post" action="/erp/captcha" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<h2>获取验证码</h2>
<p class="secondary">验证码一次有效,仅用于当前后端进程中的 ERP 会话。</p>
<button class="button primary" type="submit" data-loading-label="正在获取…">获取验证码</button>
</form>
{{end}}
</main>
</body>
</html>
{{end}}
@@ -18,6 +18,7 @@
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
<a href="/erp" {{if .Page.ERPCurrent}}aria-current="page"{{end}}>ERP 连接</a>
</nav>
{{if .Page.CSRFToken}}
<form class="logout-form" method="post" action="/logout">
+35 -6
View File
@@ -8,12 +8,17 @@ import (
)
var (
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("resource forbidden")
ErrConflict = errors.New("resource conflict")
ErrValidation = errors.New("validation failed")
ErrInvalidFile = errors.New("invalid file")
ErrUnavailable = errors.New("service unavailable")
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("resource forbidden")
ErrConflict = errors.New("resource conflict")
ErrValidation = errors.New("validation failed")
ErrInvalidFile = errors.New("invalid file")
ErrUnavailable = errors.New("service unavailable")
ErrERPNotConfigured = errors.New("ERP is not configured")
ErrERPSessionNeeded = errors.New("ERP session is required")
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
ErrERPLoginRejected = errors.New("ERP login was rejected")
ErrERPProtocol = errors.New("ERP protocol is invalid")
)
// Service is the application boundary required by the server-rendered admin UI.
@@ -38,6 +43,30 @@ type FreightService interface {
) (FreightSync, error)
}
type ERPConnectionService interface {
ERPConnectionStatus(context.Context) (ERPConnectionStatus, error)
RequestERPCaptcha(context.Context) (ERPConnectionStatus, error)
OpenERPCaptcha(context.Context, string) (ERPCaptchaImage, error)
LoginERP(context.Context, ERPLoginInput) (ERPConnectionStatus, error)
}
type ERPConnectionStatus struct {
Configured bool
Authenticated bool
CaptchaReady bool
CaptchaTicket string
}
type ERPCaptchaImage struct {
Content []byte
ContentType string
}
type ERPLoginInput struct {
CaptchaTicket string
CaptchaCode string
}
type ProcurementService interface {
CreateProcurementRequest(
context.Context,
@@ -7,6 +7,7 @@ import (
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/shunyunbao"
"cmroubao/backend-api/internal/transport/authcommon"
"cmroubao/backend-api/internal/usecase"
)
@@ -19,6 +20,7 @@ type UsecaseAdapter struct {
authorizations *usecase.OrderAuthorizationService
freight *usecase.FreightService
procurement *usecase.ProcurementService
erp *shunyunbao.SessionManager
}
func (adapter *UsecaseAdapter) SetProcurement(
@@ -27,6 +29,65 @@ func (adapter *UsecaseAdapter) SetProcurement(
adapter.procurement = procurement
}
func (adapter *UsecaseAdapter) SetERPConnection(
manager *shunyunbao.SessionManager,
) {
adapter.erp = manager
}
func (adapter *UsecaseAdapter) ERPConnectionStatus(
context.Context,
) (ERPConnectionStatus, error) {
if adapter.erp == nil {
return ERPConnectionStatus{}, ErrUnavailable
}
return erpConnectionStatusFrom(adapter.erp.Status()), nil
}
func (adapter *UsecaseAdapter) RequestERPCaptcha(
ctx context.Context,
) (ERPConnectionStatus, error) {
if adapter.erp == nil {
return ERPConnectionStatus{}, ErrUnavailable
}
status, err := adapter.erp.FetchCaptcha(ctx)
if err != nil {
return erpConnectionStatusFrom(status), mapERPError(err)
}
return erpConnectionStatusFrom(status), nil
}
func (adapter *UsecaseAdapter) OpenERPCaptcha(
_ context.Context,
ticket string,
) (ERPCaptchaImage, error) {
if adapter.erp == nil {
return ERPCaptchaImage{}, ErrUnavailable
}
image, err := adapter.erp.OpenCaptcha(ticket)
if err != nil {
return ERPCaptchaImage{}, mapERPError(err)
}
return ERPCaptchaImage{
Content: image.Content,
ContentType: image.ContentType,
}, nil
}
func (adapter *UsecaseAdapter) LoginERP(
ctx context.Context,
input ERPLoginInput,
) (ERPConnectionStatus, error) {
if adapter.erp == nil {
return ERPConnectionStatus{}, ErrUnavailable
}
status, err := adapter.erp.Login(ctx, input.CaptchaTicket, input.CaptchaCode)
if err != nil {
return erpConnectionStatusFrom(status), mapERPError(err)
}
return erpConnectionStatusFrom(status), nil
}
func NewUsecaseAdapter(
tasks *usecase.TaskService,
assets *usecase.AssetService,
@@ -840,6 +901,38 @@ func mapUsecaseError(err error) error {
}
}
func mapERPError(err error) error {
var public error
switch {
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
public = ErrERPNotConfigured
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
public = ErrERPSessionNeeded
case errors.Is(err, shunyunbao.ErrCaptchaTicketInvalid):
public = ErrERPCaptchaInvalid
case errors.Is(err, shunyunbao.ErrLoginRejected):
public = ErrERPLoginRejected
case errors.Is(err, domain.ErrFreightSourceProtocol):
public = ErrERPProtocol
case errors.Is(err, domain.ErrFreightSourceUnavailable):
public = ErrUnavailable
default:
return err
}
return &adapterError{public: public, cause: err}
}
func erpConnectionStatusFrom(
status shunyunbao.SessionStatus,
) ERPConnectionStatus {
return ERPConnectionStatus{
Configured: status.Configured,
Authenticated: status.Authenticated,
CaptchaReady: status.CaptchaReady,
CaptchaTicket: status.CaptchaTicket,
}
}
type adapterError struct {
public error
cause error
@@ -856,3 +949,4 @@ func (err *adapterError) Unwrap() []error {
var _ Service = (*UsecaseAdapter)(nil)
var _ FreightService = (*UsecaseAdapter)(nil)
var _ ProcurementService = (*UsecaseAdapter)(nil)
var _ ERPConnectionService = (*UsecaseAdapter)(nil)