fix(t233): add redacted ERP diagnostics
This commit is contained in:
@@ -3,3 +3,4 @@ CMROUBAO_SHUNYUNBAO_URL=https://www.shunyunbaoerp.com
|
|||||||
CMROUBAO_SHUNYUNBAO_USERNAME=
|
CMROUBAO_SHUNYUNBAO_USERNAME=
|
||||||
CMROUBAO_SHUNYUNBAO_PASSWORD=
|
CMROUBAO_SHUNYUNBAO_PASSWORD=
|
||||||
CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr
|
CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr
|
||||||
|
CMROUBAO_ERP_DEBUG_LOG=false
|
||||||
|
|||||||
@@ -237,12 +237,20 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var erpDiagnosticLogger shunyunbao.DiagnosticLogger
|
||||||
|
if cfg.ERPDebugLog {
|
||||||
|
log.Print("ERP diagnostic logging enabled; response summaries are redacted")
|
||||||
|
erpDiagnosticLogger = func(event string) {
|
||||||
|
log.Print(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
|
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
|
||||||
BaseURL: cfg.ShunyunbaoURL,
|
BaseURL: cfg.ShunyunbaoURL,
|
||||||
Username: cfg.ShunyunbaoUsername,
|
Username: cfg.ShunyunbaoUsername,
|
||||||
Password: cfg.ShunyunbaoPassword,
|
Password: cfg.ShunyunbaoPassword,
|
||||||
Timeout: cfg.ShunyunbaoTimeout,
|
Timeout: cfg.ShunyunbaoTimeout,
|
||||||
CaptchaRecognizer: ocr,
|
CaptchaRecognizer: ocr,
|
||||||
|
DiagnosticLogger: erpDiagnosticLogger,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME"
|
ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME"
|
||||||
ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD"
|
ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD"
|
||||||
OCRAPIURLEnvironment = "CMROUBAO_OCR_API_URL"
|
OCRAPIURLEnvironment = "CMROUBAO_OCR_API_URL"
|
||||||
|
ERPDebugLogEnvironment = "CMROUBAO_ERP_DEBUG_LOG"
|
||||||
|
|
||||||
defaultHTTPAddress = "127.0.0.1:8080"
|
defaultHTTPAddress = "127.0.0.1:8080"
|
||||||
defaultDatabasePath = "var/cmroubao.db"
|
defaultDatabasePath = "var/cmroubao.db"
|
||||||
@@ -55,6 +56,7 @@ type Config struct {
|
|||||||
ShunyunbaoPassword string
|
ShunyunbaoPassword string
|
||||||
OCRAPIURL string
|
OCRAPIURL string
|
||||||
ShunyunbaoTimeout time.Duration
|
ShunyunbaoTimeout time.Duration
|
||||||
|
ERPDebugLog bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(lookup LookupEnvironment) (Config, error) {
|
func Load(lookup LookupEnvironment) (Config, error) {
|
||||||
@@ -186,6 +188,10 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
if ocrAPISet && !validOCRAPIURL(ocrAPIURL) {
|
if ocrAPISet && !validOCRAPIURL(ocrAPIURL) {
|
||||||
return Config{}, errors.New(OCRAPIURLEnvironment + " must be an approved OCR endpoint")
|
return Config{}, errors.New(OCRAPIURLEnvironment + " must be an approved OCR endpoint")
|
||||||
}
|
}
|
||||||
|
erpDebugLog, err := booleanEnvironment(lookup, ERPDebugLogEnvironment, false)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return Config{
|
return Config{
|
||||||
HTTPAddress: httpAddress,
|
HTTPAddress: httpAddress,
|
||||||
@@ -207,6 +213,7 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
ShunyunbaoPassword: shunyunbaoPassword,
|
ShunyunbaoPassword: shunyunbaoPassword,
|
||||||
OCRAPIURL: ocrAPIURL,
|
OCRAPIURL: ocrAPIURL,
|
||||||
ShunyunbaoTimeout: 30 * time.Second,
|
ShunyunbaoTimeout: 30 * time.Second,
|
||||||
|
ERPDebugLog: erpDebugLog,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +274,25 @@ func durationEnvironment(
|
|||||||
return duration, nil
|
return duration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func booleanEnvironment(
|
||||||
|
lookup LookupEnvironment,
|
||||||
|
name string,
|
||||||
|
defaultValue bool,
|
||||||
|
) (bool, error) {
|
||||||
|
value, exists := lookup(name)
|
||||||
|
if !exists {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "true":
|
||||||
|
return true, nil
|
||||||
|
case "false":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New(name + " must be true or false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cleanOptionalPath(value string) string {
|
func cleanOptionalPath(value string) string {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if cfg.ShunyunbaoURL != "https://www.shunyunbaoerp.com" ||
|
if cfg.ShunyunbaoURL != "https://www.shunyunbaoerp.com" ||
|
||||||
cfg.ShunyunbaoUsername != "" || cfg.ShunyunbaoPassword != "" ||
|
cfg.ShunyunbaoUsername != "" || cfg.ShunyunbaoPassword != "" ||
|
||||||
cfg.ShunyunbaoTimeout != 30*time.Second {
|
cfg.ShunyunbaoTimeout != 30*time.Second || cfg.ERPDebugLog {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"shunyunbao defaults = %q / %q / %q / %s",
|
"shunyunbao defaults = %q / %q / %q / %s",
|
||||||
cfg.ShunyunbaoURL,
|
cfg.ShunyunbaoURL,
|
||||||
@@ -68,6 +68,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
ShunyunbaoURLEnvironment: "https://erp.example.test:8443",
|
ShunyunbaoURLEnvironment: "https://erp.example.test:8443",
|
||||||
ShunyunbaoUsernameEnvironment: "service-user",
|
ShunyunbaoUsernameEnvironment: "service-user",
|
||||||
ShunyunbaoPasswordEnvironment: " pass with spaces ",
|
ShunyunbaoPasswordEnvironment: " pass with spaces ",
|
||||||
|
ERPDebugLogEnvironment: "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := Load(mapEnvironment(values))
|
cfg, err := Load(mapEnvironment(values))
|
||||||
@@ -104,7 +105,8 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if cfg.ShunyunbaoURL != values[ShunyunbaoURLEnvironment] ||
|
if cfg.ShunyunbaoURL != values[ShunyunbaoURLEnvironment] ||
|
||||||
cfg.ShunyunbaoUsername != values[ShunyunbaoUsernameEnvironment] ||
|
cfg.ShunyunbaoUsername != values[ShunyunbaoUsernameEnvironment] ||
|
||||||
cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] {
|
cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] ||
|
||||||
|
!cfg.ERPDebugLog {
|
||||||
t.Fatalf("shunyunbao config was not preserved")
|
t.Fatalf("shunyunbao config was not preserved")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,12 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
|||||||
ShunyunbaoPasswordEnvironment: "password",
|
ShunyunbaoPasswordEnvironment: "password",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "invalid ERP debug log",
|
||||||
|
values: map[string]string{
|
||||||
|
ERPDebugLogEnvironment: "yes",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "blank explicit address",
|
name: "blank explicit address",
|
||||||
values: map[string]string{
|
values: map[string]string{
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ func isERPEnvironmentName(name string) bool {
|
|||||||
case ShunyunbaoURLEnvironment,
|
case ShunyunbaoURLEnvironment,
|
||||||
ShunyunbaoUsernameEnvironment,
|
ShunyunbaoUsernameEnvironment,
|
||||||
ShunyunbaoPasswordEnvironment,
|
ShunyunbaoPasswordEnvironment,
|
||||||
OCRAPIURLEnvironment:
|
OCRAPIURLEnvironment,
|
||||||
|
ERPDebugLogEnvironment:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
|
|||||||
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
||||||
"CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'",
|
"CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'",
|
||||||
"CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr",
|
"CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr",
|
||||||
|
"CMROUBAO_ERP_DEBUG_LOG=true",
|
||||||
}, "\n"))
|
}, "\n"))
|
||||||
|
|
||||||
lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) {
|
lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) {
|
||||||
@@ -29,7 +30,7 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
|
|||||||
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
||||||
cfg.ShunyunbaoUsername != "dotenv-user" ||
|
cfg.ShunyunbaoUsername != "dotenv-user" ||
|
||||||
cfg.ShunyunbaoPassword != "dotenv password #1" ||
|
cfg.ShunyunbaoPassword != "dotenv password #1" ||
|
||||||
cfg.OCRAPIURL != "http://127.0.0.1:8000/ocr" {
|
cfg.OCRAPIURL != "http://127.0.0.1:8000/ocr" || !cfg.ERPDebugLog {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"ERP config = %#v",
|
"ERP config = %#v",
|
||||||
struct {
|
struct {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
defaultCaptchaTTL = 5 * time.Minute
|
defaultCaptchaTTL = 5 * time.Minute
|
||||||
maxCaptchaBytes = 2 << 20
|
maxCaptchaBytes = 2 << 20
|
||||||
maxERPResponseBytes = 4 << 20
|
maxERPResponseBytes = 4 << 20
|
||||||
|
maxDiagnosticBytes = 4 << 10
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -40,12 +41,17 @@ type SessionConfig struct {
|
|||||||
CaptchaTTL time.Duration
|
CaptchaTTL time.Duration
|
||||||
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
|
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
|
||||||
CaptchaRecognizer CaptchaRecognizer
|
CaptchaRecognizer CaptchaRecognizer
|
||||||
|
DiagnosticLogger DiagnosticLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaptchaRecognizer interface {
|
type CaptchaRecognizer interface {
|
||||||
Recognize(context.Context, []byte, string) (string, error)
|
Recognize(context.Context, []byte, string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiagnosticLogger receives only redacted request/response summaries when
|
||||||
|
// explicitly enabled by the API composition root.
|
||||||
|
type DiagnosticLogger func(string)
|
||||||
|
|
||||||
type SessionStatus struct {
|
type SessionStatus struct {
|
||||||
Configured bool
|
Configured bool
|
||||||
Authenticated bool
|
Authenticated bool
|
||||||
@@ -69,6 +75,8 @@ type SessionManager struct {
|
|||||||
headers http.Header
|
headers http.Header
|
||||||
http *http.Client
|
http *http.Client
|
||||||
recognizer CaptchaRecognizer
|
recognizer CaptchaRecognizer
|
||||||
|
diagnosticLog DiagnosticLogger
|
||||||
|
diagnosticsOn bool
|
||||||
authenticated bool
|
authenticated bool
|
||||||
captchaTicket string
|
captchaTicket string
|
||||||
captchaContent []byte
|
captchaContent []byte
|
||||||
@@ -118,7 +126,9 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) {
|
|||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recognizer: config.CaptchaRecognizer,
|
recognizer: config.CaptchaRecognizer,
|
||||||
|
diagnosticLog: config.DiagnosticLogger,
|
||||||
|
diagnosticsOn: config.DiagnosticLogger != nil,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,12 +189,15 @@ func (manager *SessionManager) FetchCaptcha(
|
|||||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||||
}
|
}
|
||||||
manager.applyHeaders(request)
|
manager.applyHeaders(request)
|
||||||
|
manager.logERPRequest(request)
|
||||||
response, err := manager.http.Do(request)
|
response, err := manager.http.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
manager.logERPTransportFailure(request)
|
||||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
|
manager.logERPResponsePreview(request, response)
|
||||||
return manager.statusLocked(), manager.responseErrorLocked(response.StatusCode)
|
return manager.statusLocked(), manager.responseErrorLocked(response.StatusCode)
|
||||||
}
|
}
|
||||||
contentType := strings.TrimSpace(
|
contentType := strings.TrimSpace(
|
||||||
@@ -195,8 +208,10 @@ func (manager *SessionManager) FetchCaptcha(
|
|||||||
}
|
}
|
||||||
content, err := readBounded(response.Body, maxCaptchaBytes)
|
content, err := readBounded(response.Body, maxCaptchaBytes)
|
||||||
if err != nil || len(content) == 0 {
|
if err != nil || len(content) == 0 {
|
||||||
|
manager.logERPResponseReadFailure(request, response)
|
||||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||||
}
|
}
|
||||||
|
manager.logERPResponse(request, response, content, false)
|
||||||
ticket, err := newCaptchaTicket()
|
ticket, err := newCaptchaTicket()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||||
@@ -327,12 +342,15 @@ func (manager *SessionManager) requestJSONLocked(
|
|||||||
if body != nil {
|
if body != nil {
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
manager.logERPRequest(request)
|
||||||
response, err := manager.http.Do(request)
|
response, err := manager.http.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
manager.logERPTransportFailure(request)
|
||||||
return nil, domain.ErrFreightSourceUnavailable
|
return nil, domain.ErrFreightSourceUnavailable
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
manager.logERPResponsePreview(request, response)
|
||||||
if loginRequest {
|
if loginRequest {
|
||||||
return nil, ErrLoginRejected
|
return nil, ErrLoginRejected
|
||||||
}
|
}
|
||||||
@@ -340,8 +358,10 @@ func (manager *SessionManager) requestJSONLocked(
|
|||||||
}
|
}
|
||||||
contentBytes, err := readBounded(response.Body, maxERPResponseBytes)
|
contentBytes, err := readBounded(response.Body, maxERPResponseBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
manager.logERPResponseReadFailure(request, response)
|
||||||
return nil, domain.ErrFreightSourceUnavailable
|
return nil, domain.ErrFreightSourceUnavailable
|
||||||
}
|
}
|
||||||
|
manager.logERPResponse(request, response, contentBytes, false)
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Status *bool `json:"status"`
|
Status *bool `json:"status"`
|
||||||
Code json.RawMessage `json:"code"`
|
Code json.RawMessage `json:"code"`
|
||||||
@@ -439,6 +459,209 @@ func (manager *SessionManager) clearCaptchaLocked() {
|
|||||||
manager.captchaExpires = time.Time{}
|
manager.captchaExpires = time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (manager *SessionManager) logERPRequest(request *http.Request) {
|
||||||
|
if !manager.diagnosticsOn {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.diagnosticLog(
|
||||||
|
"erp_request method=" + request.Method +
|
||||||
|
" path=" + request.URL.EscapedPath(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SessionManager) logERPTransportFailure(request *http.Request) {
|
||||||
|
if !manager.diagnosticsOn {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.diagnosticLog(
|
||||||
|
"erp_transport_failed method=" + request.Method +
|
||||||
|
" path=" + request.URL.EscapedPath() + " class=transport",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SessionManager) logERPResponsePreview(
|
||||||
|
request *http.Request,
|
||||||
|
response *http.Response,
|
||||||
|
) {
|
||||||
|
if !manager.diagnosticsOn {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, truncated, readable := readDiagnosticPreview(response.Body)
|
||||||
|
if !readable {
|
||||||
|
manager.logERPResponseReadFailure(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.logERPResponse(request, response, content, truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SessionManager) logERPResponseReadFailure(
|
||||||
|
request *http.Request,
|
||||||
|
response *http.Response,
|
||||||
|
) {
|
||||||
|
if !manager.diagnosticsOn {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.diagnosticLog(
|
||||||
|
"erp_response method=" + request.Method +
|
||||||
|
" path=" + request.URL.EscapedPath() +
|
||||||
|
" status=" + strconv.Itoa(response.StatusCode) +
|
||||||
|
" content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) +
|
||||||
|
" body=unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SessionManager) logERPResponse(
|
||||||
|
request *http.Request,
|
||||||
|
response *http.Response,
|
||||||
|
content []byte,
|
||||||
|
truncated bool,
|
||||||
|
) {
|
||||||
|
if !manager.diagnosticsOn {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byteCount := "bytes=" + strconv.Itoa(len(content))
|
||||||
|
body := "body=empty"
|
||||||
|
if truncated {
|
||||||
|
byteCount = "bytes_at_least=" + strconv.Itoa(len(content))
|
||||||
|
body = "body=omitted_truncated"
|
||||||
|
} else if len(content) > 0 {
|
||||||
|
body = "body=omitted_non_json"
|
||||||
|
if summary, ok := redactedDiagnosticJSON(content); ok {
|
||||||
|
body = "json=" + summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager.diagnosticLog(
|
||||||
|
"erp_response method=" + request.Method +
|
||||||
|
" path=" + request.URL.EscapedPath() +
|
||||||
|
" status=" + strconv.Itoa(response.StatusCode) +
|
||||||
|
" content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) +
|
||||||
|
" " + byteCount + " " + body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDiagnosticPreview(reader io.Reader) ([]byte, bool, bool) {
|
||||||
|
content, err := io.ReadAll(io.LimitReader(reader, maxDiagnosticBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
if len(content) > maxDiagnosticBytes {
|
||||||
|
return content[:maxDiagnosticBytes], true, true
|
||||||
|
}
|
||||||
|
return content, false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticContentType(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.Split(value, ";")[0])
|
||||||
|
if value == "" || len(value) > 128 || !utf8.ValidString(value) || hasControl(value) {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactedDiagnosticJSON(content []byte) (string, bool) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var value any
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(redactDiagnosticValue(value))
|
||||||
|
if err != nil || len(encoded) > maxDiagnosticBytes {
|
||||||
|
return `{"summary":"omitted_large_json"}`, true
|
||||||
|
}
|
||||||
|
return string(encoded), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactDiagnosticValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
redacted := make(map[string]any, len(typed))
|
||||||
|
for key, item := range typed {
|
||||||
|
if sensitiveDiagnosticKey(key) {
|
||||||
|
redacted[key] = "[REDACTED]"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
redacted[key] = redactDiagnosticValue(item)
|
||||||
|
}
|
||||||
|
return redacted
|
||||||
|
case []any:
|
||||||
|
limit := len(typed)
|
||||||
|
if limit > 20 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
redacted := make([]any, 0, limit+1)
|
||||||
|
for _, item := range typed[:limit] {
|
||||||
|
redacted = append(redacted, redactDiagnosticValue(item))
|
||||||
|
}
|
||||||
|
if len(typed) > limit {
|
||||||
|
redacted = append(redacted, "[TRUNCATED]")
|
||||||
|
}
|
||||||
|
return redacted
|
||||||
|
case string:
|
||||||
|
return redactDiagnosticString(typed)
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sensitiveDiagnosticKey(value string) bool {
|
||||||
|
var normalized strings.Builder
|
||||||
|
for _, character := range strings.ToLower(value) {
|
||||||
|
if (character >= 'a' && character <= 'z') ||
|
||||||
|
(character >= '0' && character <= '9') {
|
||||||
|
normalized.WriteRune(character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := normalized.String()
|
||||||
|
for _, marker := range []string{
|
||||||
|
"password", "passwd", "pwd", "username", "user", "captcha", "token",
|
||||||
|
"cookie", "authorization", "auth", "receiver", "recipient", "phone",
|
||||||
|
"mobile", "tel", "address", "email", "order", "stock", "tracking",
|
||||||
|
"track", "express", "shipment", "shop", "name", "id", "code", "remark",
|
||||||
|
"note", "detail",
|
||||||
|
} {
|
||||||
|
if strings.Contains(key, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactDiagnosticString(value string) string {
|
||||||
|
value = strings.ToValidUTF8(strings.TrimSpace(value), "?")
|
||||||
|
value = strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(value)
|
||||||
|
if strings.Contains(value, "@") {
|
||||||
|
return "[REDACTED]"
|
||||||
|
}
|
||||||
|
var result strings.Builder
|
||||||
|
for index := 0; index < len(value); {
|
||||||
|
if value[index] < '0' || value[index] > '9' {
|
||||||
|
result.WriteByte(value[index])
|
||||||
|
index++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := index
|
||||||
|
for end < len(value) && value[end] >= '0' && value[end] <= '9' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if end-index >= 6 {
|
||||||
|
result.WriteString("[REDACTED]")
|
||||||
|
} else {
|
||||||
|
result.WriteString(value[index:end])
|
||||||
|
}
|
||||||
|
index = end
|
||||||
|
}
|
||||||
|
characters := []rune(result.String())
|
||||||
|
if len(characters) > 512 {
|
||||||
|
return string(characters[:512]) + "[TRUNCATED]"
|
||||||
|
}
|
||||||
|
return string(characters)
|
||||||
|
}
|
||||||
|
|
||||||
func hasUser(value any) bool {
|
func hasUser(value any) bool {
|
||||||
data, ok := value.(map[string]any)
|
data, ok := value.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -211,6 +211,65 @@ func TestSessionManagerEnsureAuthenticatedRequiresRecognizer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionManagerDiagnosticLogsAreRedacted(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case CaptchaPath:
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte("private-captcha-image"))
|
||||||
|
case LoginPath:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status":false,
|
||||||
|
"msg":"ERP says order 123456789 is blocked",
|
||||||
|
"data":{"username":"test-user","password":"test-password","token":"secret-token","orderNumber":"order-123456789"}
|
||||||
|
}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
var events []string
|
||||||
|
manager, err := NewSessionManager(SessionConfig{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Username: "test-user",
|
||||||
|
Password: "test-password",
|
||||||
|
Timeout: time.Second,
|
||||||
|
AllowInsecureHTTP: true,
|
||||||
|
CaptchaRecognizer: &fixedRecognizer{code: "1234"},
|
||||||
|
DiagnosticLogger: func(event string) {
|
||||||
|
events = append(events, event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionManager() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := manager.EnsureAuthenticated(context.Background()); !errors.Is(err, domain.ErrFreightSourceLoginRejected) {
|
||||||
|
t.Fatalf("EnsureAuthenticated() error = %v", err)
|
||||||
|
}
|
||||||
|
actual := strings.Join(events, "\n")
|
||||||
|
for _, expected := range []string{
|
||||||
|
"erp_request method=GET path=/api/p/code1",
|
||||||
|
"erp_response method=GET path=/api/p/code1 status=200",
|
||||||
|
"body=omitted_non_json",
|
||||||
|
"erp_request method=POST path=/am/auth/login",
|
||||||
|
"erp_response method=POST path=/am/auth/login status=200",
|
||||||
|
`"status":false`,
|
||||||
|
`"password":"[REDACTED]"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(actual, expected) {
|
||||||
|
t.Fatalf("diagnostic log missing %q: %s", expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, secret := range []string{
|
||||||
|
"test-user", "test-password", "secret-token", "123456789", "private-captcha-image",
|
||||||
|
} {
|
||||||
|
if strings.Contains(actual, secret) {
|
||||||
|
t.Fatalf("diagnostic log leaked %q: %s", secret, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fixedRecognizer struct {
|
type fixedRecognizer struct {
|
||||||
code string
|
code string
|
||||||
err error
|
err error
|
||||||
|
|||||||
@@ -25,3 +25,21 @@ func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapFreightCreateErrorPreservesPreflightErrors(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
err error
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid},
|
||||||
|
{domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured},
|
||||||
|
{domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected},
|
||||||
|
{domain.ErrFreightSourceProtocol, ErrERPProtocol},
|
||||||
|
{domain.ErrFreightSourceUnavailable, ErrERPUnavailable},
|
||||||
|
} {
|
||||||
|
actual := mapFreightCreateError(testCase.err)
|
||||||
|
if !errors.Is(actual, testCase.want) || !errors.Is(actual, testCase.err) {
|
||||||
|
t.Fatalf("mapFreightCreateError(%v) = %v", testCase.err, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -379,14 +379,18 @@ func (adapter *UsecaseAdapter) CreateFreightSync(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
return FreightSync{}, mapFreightCreateError(err)
|
||||||
return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err}
|
|
||||||
}
|
|
||||||
return FreightSync{}, mapUsecaseError(err)
|
|
||||||
}
|
}
|
||||||
return freightSyncFrom(result.Run), nil
|
return freightSyncFrom(result.Run), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapFreightCreateError(err error) error {
|
||||||
|
if mapped := mapFreightPreflightError(err); mapped != nil {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
return mapUsecaseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
func (adapter *UsecaseAdapter) GetFreightWatermark(
|
func (adapter *UsecaseAdapter) GetFreightWatermark(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (*FreightWatermark, error) {
|
) (*FreightWatermark, error) {
|
||||||
|
|||||||
@@ -220,6 +220,12 @@ ERP 配置来源:
|
|||||||
`422 ERP_LOGIN_REJECTED`、`502 ERP_RESPONSE_INVALID` 和 `503 ERP_UNAVAILABLE`;SSR 导入页
|
`422 ERP_LOGIN_REJECTED`、`502 ERP_RESPONSE_INVALID` 和 `503 ERP_UNAVAILABLE`;SSR 导入页
|
||||||
用相同 code 显示可关闭弹窗并保留表单。所有 message 都是固定匿名文本,不含 ERP/OCR 原始
|
用相同 code 显示可关闭弹窗并保留表单。所有 message 都是固定匿名文本,不含 ERP/OCR 原始
|
||||||
响应、验证码、Cookie、账号或密码。
|
响应、验证码、Cookie、账号或密码。
|
||||||
|
- `CMROUBAO_ERP_DEBUG_LOG` 默认 `false`,仅接受 `true` 或 `false`。临时设为 `true` 后,
|
||||||
|
API 启动窗口记录 ERP request 的 method/path,以及 response 的 status、content type、长度和
|
||||||
|
最多 4 KiB 的递归脱敏 JSON 摘要;不记录请求 body、完整 URL query、header、Cookie、验证码、
|
||||||
|
OCR 文本、账号、密码、token、订单标识、收件信息、非 JSON body 或图片。诊断完成后必须设回
|
||||||
|
`false` 并重启 API。Windows 可使用根目录 `start-backend.bat --erp-debug` 仅为本次 API
|
||||||
|
进程覆盖开启;也可与 `--migrate` 组合。
|
||||||
|
|
||||||
会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。
|
会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-29
|
- 日期:2026-07-29
|
||||||
- 阶段:T-233 计划修复 ERP 预检错误映射并增加受控诊断日志
|
- 阶段:T-233 已修复 ERP 预检错误映射并增加受控诊断日志
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -190,6 +190,8 @@
|
|||||||
| `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 ERP 凭证 |
|
| `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 ERP 凭证 |
|
||||||
| `docs/tasks/T-230.md` | DONE | OCR 自动登录并移除人工 ERP 连接页 |
|
| `docs/tasks/T-230.md` | DONE | OCR 自动登录并移除人工 ERP 连接页 |
|
||||||
| `docs/tasks/T-231.md` | DONE | 显示 ERP/OCR 导入预检稳定错误 |
|
| `docs/tasks/T-231.md` | DONE | 显示 ERP/OCR 导入预检稳定错误 |
|
||||||
|
| `docs/tasks/T-232.md` | DONE | 记录 ERP/OCR 导入预检安全诊断日志 |
|
||||||
|
| `docs/tasks/T-233.md` | DONE | 修复预检错误映射并增加受控 ERP 诊断日志 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
@@ -201,13 +203,13 @@
|
|||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
||||||
- 已完成:另含 T-220 至 T-231 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
- 已完成:另含 T-220 至 T-233 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
||||||
直连协议、OCR 会话预检、稳定预检错误、直连 `FreightSource`、旧 Connector 清理和受控本地
|
直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector
|
||||||
凭证加载。
|
清理和受控本地凭证加载。
|
||||||
- 进行中:T-233 修复将 ERP 预检错误降级为通用 `503` 的 Web adapter 映射,并增加默认关闭的
|
- 进行中:无。
|
||||||
ERP 脱敏诊断日志。
|
- 下一步:使用 `start-backend.bat --erp-debug` 重启 API 后以受控单号导入一次,读取 ERP
|
||||||
- 下一步:启用受控诊断日志后以受控单号导入一次,读取 ERP 请求/响应摘要和货运预检 code;
|
请求/响应摘要和货运预检 code;再确认开放 API、OCR/ERP 数据使用权限并完成不记录订单内容的
|
||||||
再确认开放 API、OCR/ERP 数据使用权限并完成不记录订单内容的 smoke。
|
smoke。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -4,7 +4,7 @@ title: 修复 ERP 预检错误映射并增加受控诊断日志
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-232
|
- T-232
|
||||||
status: PLANNED
|
status: DONE
|
||||||
created: 2026-07-29
|
created: 2026-07-29
|
||||||
context_ref: b316287
|
context_ref: b316287
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -12,6 +12,7 @@ write_paths:
|
|||||||
- docs/tasks/T-233.md
|
- docs/tasks/T-233.md
|
||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
- docs/api.md
|
- docs/api.md
|
||||||
|
- start-backend.bat
|
||||||
- backend-api/.env.example
|
- backend-api/.env.example
|
||||||
- backend-api/cmd/api/**
|
- backend-api/cmd/api/**
|
||||||
- backend-api/internal/config/**
|
- backend-api/internal/config/**
|
||||||
@@ -37,17 +38,18 @@ client 不记录请求路径、HTTP 状态或响应摘要,无法区分 TLS/网
|
|||||||
token、Cookie、收件人、电话、地址和订单标识均替换为 `[REDACTED]`。图片、非 JSON 和
|
token、Cookie、收件人、电话、地址和订单标识均替换为 `[REDACTED]`。图片、非 JSON 和
|
||||||
传输失败只输出类型、长度、状态或固定类别,不输出原始内容或 Go error。
|
传输失败只输出类型、长度、状态或固定类别,不输出原始内容或 Go error。
|
||||||
4. 不记录请求 body、完整 URL query、header、Cookie、OCR 文本或密钥。启动时仅记录调试
|
4. 不记录请求 body、完整 URL query、header、Cookie、OCR 文本或密钥。启动时仅记录调试
|
||||||
已启用,不输出配置值;调试结束后将 `.env` 设回 `false`。
|
已启用,不输出配置值;调试结束后将 `.env` 设回 `false`。根目录
|
||||||
|
`start-backend.bat --erp-debug` 可只对本次 API 进程开启同一开关。
|
||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] ERP 不可用可从 `/freight/import` 日志看到
|
- [x] ERP 不可用可从 `/freight/import` 日志看到
|
||||||
`freight_import_preflight_failed code=ERP_UNAVAILABLE status=503`。
|
`freight_import_preflight_failed code=ERP_UNAVAILABLE status=503`。
|
||||||
- [ ] 开启 `CMROUBAO_ERP_DEBUG_LOG=true` 后,验证码、登录、用户校验及后续查询均有可关联
|
- [x] 开启 `CMROUBAO_ERP_DEBUG_LOG=true` 后,验证码、登录、用户校验及后续查询均有可关联
|
||||||
的脱敏请求/响应日志;默认关闭时没有这些日志。
|
的脱敏请求/响应日志;默认关闭时没有这些日志。
|
||||||
- [ ] 日志绝不含 ERP 凭证、验证码、Cookie、token、订单号、收件人、手机号、地址或原始
|
- [x] 日志绝不含 ERP 凭证、验证码、Cookie、token、订单号、收件人、手机号、地址或原始
|
||||||
非 JSON/图片 body。
|
非 JSON/图片 body。
|
||||||
- [ ] 配置、脱敏、ERP client 和 Web adapter 均有测试;标准 Go 验证和入口构建通过。
|
- [x] 配置、脱敏、ERP client 和 Web adapter 均有测试;标准 Go 验证和入口构建通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -59,3 +61,6 @@ client 不记录请求路径、HTTP 状态或响应摘要,无法区分 TLS/网
|
|||||||
|
|
||||||
- 2026-07-29:创建任务。现场终端提供 `freight_import_failed status=503`,确认稳定 code 在
|
- 2026-07-29:创建任务。现场终端提供 `freight_import_failed status=503`,确认稳定 code 在
|
||||||
Web adapter 丢失;本机没有可重放的真实 ERP 账户响应。
|
Web adapter 丢失;本机没有可重放的真实 ERP 账户响应。
|
||||||
|
- 2026-07-29:修复 CreateFreightSync 复用所有预检错误映射,增加受控 ERP 诊断 logger、
|
||||||
|
JSON 递归脱敏和 4 KiB 限制,并提供 `start-backend.bat --erp-debug` 一次性开关。标准 Go
|
||||||
|
测试、race、vet 和三个入口构建均通过;未请求真实 ERP。
|
||||||
|
|||||||
+27
-5
@@ -2,6 +2,7 @@
|
|||||||
setlocal EnableExtensions DisableDelayedExpansion
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
|
||||||
set "PROJECT_ROOT=%~dp0"
|
set "PROJECT_ROOT=%~dp0"
|
||||||
|
set "SCRIPT_NAME=%~nx0"
|
||||||
set "BACKEND_ROOT=%PROJECT_ROOT%backend-api"
|
set "BACKEND_ROOT=%PROJECT_ROOT%backend-api"
|
||||||
set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls"
|
set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls"
|
||||||
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
|
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
|
||||||
@@ -21,11 +22,32 @@ if not defined CMROUBAO_TLS_CERT_FILE (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not "%~1"=="" if /i not "%~1"=="--migrate" (
|
set "RUN_MIGRATIONS=0"
|
||||||
echo Usage: %~nx0 [--migrate]
|
set "ERP_DEBUG_LOG=0"
|
||||||
echo --migrate Apply pending database migrations before starting the API.
|
:parse_arguments
|
||||||
exit /b 2
|
if "%~1"=="" goto :arguments_parsed
|
||||||
|
if /i "%~1"=="--migrate" (
|
||||||
|
if "%RUN_MIGRATIONS%"=="1" goto :usage
|
||||||
|
set "RUN_MIGRATIONS=1"
|
||||||
|
shift
|
||||||
|
goto :parse_arguments
|
||||||
)
|
)
|
||||||
|
if /i "%~1"=="--erp-debug" (
|
||||||
|
if "%ERP_DEBUG_LOG%"=="1" goto :usage
|
||||||
|
set "ERP_DEBUG_LOG=1"
|
||||||
|
shift
|
||||||
|
goto :parse_arguments
|
||||||
|
)
|
||||||
|
goto :usage
|
||||||
|
|
||||||
|
:usage
|
||||||
|
echo Usage: %SCRIPT_NAME% [--migrate] [--erp-debug]
|
||||||
|
echo --migrate Apply pending database migrations before starting the API.
|
||||||
|
echo --erp-debug Print redacted ERP request and response diagnostics for this process.
|
||||||
|
exit /b 2
|
||||||
|
|
||||||
|
:arguments_parsed
|
||||||
|
if "%ERP_DEBUG_LOG%"=="1" set "CMROUBAO_ERP_DEBUG_LOG=true"
|
||||||
|
|
||||||
if not exist "%BACKEND_ROOT%\go.mod" (
|
if not exist "%BACKEND_ROOT%\go.mod" (
|
||||||
echo Missing backend project: "%BACKEND_ROOT%"
|
echo Missing backend project: "%BACKEND_ROOT%"
|
||||||
@@ -63,7 +85,7 @@ if errorlevel 1 goto :startup_failed
|
|||||||
if not exist "var\assets" mkdir "var\assets"
|
if not exist "var\assets" mkdir "var\assets"
|
||||||
if errorlevel 1 goto :startup_failed
|
if errorlevel 1 goto :startup_failed
|
||||||
|
|
||||||
if /i "%~1"=="--migrate" (
|
if "%RUN_MIGRATIONS%"=="1" (
|
||||||
echo Applying database migrations...
|
echo Applying database migrations...
|
||||||
go run ./cmd/migrate up
|
go run ./cmd/migrate up
|
||||||
if errorlevel 1 goto :startup_failed
|
if errorlevel 1 goto :startup_failed
|
||||||
|
|||||||
Reference in New Issue
Block a user