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
@@ -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)