feat(t229): load ERP credentials from dotenv
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# Copy this file to .env and fill it locally. Do not commit .env.
|
||||||
|
CMROUBAO_SHUNYUNBAO_URL=https://www.shunyunbaoerp.com
|
||||||
|
CMROUBAO_SHUNYUNBAO_USERNAME=
|
||||||
|
CMROUBAO_SHUNYUNBAO_PASSWORD=
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
bin/
|
bin/
|
||||||
var/
|
var/
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
+22
-2
@@ -24,8 +24,28 @@ start/运行续租/release 和取消安全确认。
|
|||||||
| `CMROUBAO_CLAIM_LEASE` | `10m` | CLAIMED 租约;允许 `1m` 至 `30m` |
|
| `CMROUBAO_CLAIM_LEASE` | `10m` | CLAIMED 租约;允许 `1m` 至 `30m` |
|
||||||
| `CMROUBAO_RUNNING_LEASE` | `30m` | RUNNING/等待确认的离线执行授权;允许 `5m` 至 `120m` |
|
| `CMROUBAO_RUNNING_LEASE` | `30m` | RUNNING/等待确认的离线执行授权;允许 `5m` 至 `120m` |
|
||||||
| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` |
|
| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` |
|
||||||
|
| `CMROUBAO_SHUNYUNBAO_URL` | `https://www.shunyunbaoerp.com` | 顺运宝 HTTPS origin |
|
||||||
|
| `CMROUBAO_SHUNYUNBAO_USERNAME` | 无 | 顺运宝账号;必须与密码同时设置 |
|
||||||
|
| `CMROUBAO_SHUNYUNBAO_PASSWORD` | 无 | 顺运宝密码;必须与账号同时设置 |
|
||||||
|
|
||||||
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
|
### 本地 ERP `.env`
|
||||||
|
|
||||||
|
API 启动时只从当前目录读取 `.env`,标准启动脚本会先进入 `backend-api/`,因此文件位置
|
||||||
|
固定为 `backend-api/.env`。首次配置:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location backend-api
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
# 编辑 .env,填入顺运宝账号和密码
|
||||||
|
```
|
||||||
|
|
||||||
|
该文件只允许上述三个 `CMROUBAO_SHUNYUNBAO_*` 值,进程环境变量优先于同名 `.env` 值。
|
||||||
|
它不配置数据库、监听/TLS、`authctl` 密码或其他应用选项,也不会修改全局进程环境。缺失
|
||||||
|
`.env` 时 ERP 保持未配置,其他本地功能仍可启动。仅支持空行、整行 `#` 注释和 `KEY=VALUE`
|
||||||
|
(需要保留空格或 `#` 的值可使用成对单/双引号);不支持变量展开、命令或行内注释。
|
||||||
|
|
||||||
|
`.env` 已被 Git 忽略,应只保存在本机受限目录;`.env.example` 不得填入真实凭证。`var/`
|
||||||
|
运行数据同样不得提交。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -61,7 +81,7 @@ API 启动前会检查全部 migration 已应用;发现 pending migration 会
|
|||||||
执行 `go run ./cmd/migrate up`,不会在服务进程内自动改表。
|
执行 `go run ./cmd/migrate up`,不会在服务进程内自动改表。
|
||||||
|
|
||||||
`authctl create-user` 和 `authctl reset-password` 的密码只从
|
`authctl create-user` 和 `authctl reset-password` 的密码只从
|
||||||
`CMROUBAO_AUTH_PASSWORD` 读取,不接受命令行密码。
|
`CMROUBAO_AUTH_PASSWORD` 进程环境变量读取,不接受命令行密码或 ERP `.env`。
|
||||||
`create-device` 只在成功时输出一次设备 ID 和 256 bit 设备 token;原值应立即放入
|
`create-device` 只在成功时输出一次设备 ID 和 256 bit 设备 token;原值应立即放入
|
||||||
设备安全配置,不得写入 Git、普通日志或共享文档。`enable-user`、`disable-user`、
|
设备安全配置,不得写入 Git、普通日志或共享文档。`enable-user`、`disable-user`、
|
||||||
`enable-device`、`disable-device` 是受支持的本地停用/恢复入口;禁用会让该主体的
|
`enable-device`、`disable-device` 是受支持的本地停用/恢复入口;禁用会让该主体的
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
`go-blueprint create --name backend-api --framework gin --driver sqlite --git skip`
|
`go-blueprint create --name backend-api --framework gin --driver sqlite --git skip`
|
||||||
|
|
||||||
The generator was run once outside this repository. Its current output targets Go 1.25 and
|
The generator was run once outside this repository. Its current output targets Go 1.25 and
|
||||||
Gin 1.12 and includes demo routes, permissive CORS, implicit `.env` loading, a package-level
|
Gin 1.12 and includes demo routes, permissive CORS, a generic implicit `.env` loader, a package-level
|
||||||
database singleton, and fatal health-check behavior. Those defaults were not copied. The checked-in
|
database singleton, and fatal health-check behavior. Those defaults were not copied. The checked-in
|
||||||
code retains only the minimal command/internal package shape and is independently constrained by
|
code retains only the minimal command/internal package shape and is independently constrained by
|
||||||
this project's architecture and tests.
|
this project's architecture and tests. `cmd/api` separately uses a bounded, allowlisted `.env`
|
||||||
|
fallback for the three ERP configuration values only; it does not provide generic application
|
||||||
|
configuration loading.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
cfg, err := config.Load(os.LookupEnv)
|
cfg, err := loadConfig(os.LookupEnv, config.DefaultERPEnvironmentFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,20 @@ func run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadConfig(
|
||||||
|
lookup config.LookupEnvironment,
|
||||||
|
environmentFile string,
|
||||||
|
) (config.Config, error) {
|
||||||
|
effectiveLookup, err := config.WithERPEnvironmentFile(
|
||||||
|
environmentFile,
|
||||||
|
lookup,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
return config.Load(effectiveLookup)
|
||||||
|
}
|
||||||
|
|
||||||
func shutdownServer(
|
func shutdownServer(
|
||||||
server *http.Server,
|
server *http.Server,
|
||||||
serverErrors <-chan error,
|
serverErrors <-chan error,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +17,40 @@ import (
|
|||||||
"cmroubao/backend-api/internal/platform/migration"
|
"cmroubao/backend-api/internal/platform/migration"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestLoadConfigUsesERPEnvironmentFile(t *testing.T) {
|
||||||
|
environmentFile := filepath.Join(t.TempDir(), ".env")
|
||||||
|
err := os.WriteFile(environmentFile, []byte(
|
||||||
|
"CMROUBAO_SHUNYUNBAO_URL=https://erp.example.test\n"+
|
||||||
|
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user\n"+
|
||||||
|
"CMROUBAO_SHUNYUNBAO_PASSWORD=dotenv-password\n",
|
||||||
|
), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig(func(name string) (string, bool) {
|
||||||
|
if name == config.ShunyunbaoUsernameEnvironment {
|
||||||
|
return "process-user", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}, environmentFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
||||||
|
cfg.ShunyunbaoUsername != "process-user" ||
|
||||||
|
cfg.ShunyunbaoPassword != "dotenv-password" {
|
||||||
|
t.Fatalf(
|
||||||
|
"ERP config = %#v",
|
||||||
|
struct {
|
||||||
|
URL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}{cfg.ShunyunbaoURL, cfg.ShunyunbaoUsername, cfg.ShunyunbaoPassword},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShutdownServerForceClosesAfterGracefulTimeout(t *testing.T) {
|
func TestShutdownServerForceClosesAfterGracefulTimeout(t *testing.T) {
|
||||||
handlerStarted := make(chan struct{})
|
handlerStarted := make(chan struct{})
|
||||||
releaseHandler := make(chan struct{})
|
releaseHandler := make(chan struct{})
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultERPEnvironmentFile = ".env"
|
||||||
|
|
||||||
|
maximumERPEnvironmentFileBytes = 32 << 10
|
||||||
|
maximumERPEnvironmentLineBytes = 4 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errERPEnvironmentFileInvalid = errors.New("ERP .env file is invalid")
|
||||||
|
errERPEnvironmentLookup = errors.New("ERP .env lookup is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// WithERPEnvironmentFile returns a lookup that uses the process environment
|
||||||
|
// first and only falls back to approved ERP values from path.
|
||||||
|
func WithERPEnvironmentFile(
|
||||||
|
path string,
|
||||||
|
parent LookupEnvironment,
|
||||||
|
) (LookupEnvironment, error) {
|
||||||
|
if parent == nil {
|
||||||
|
return nil, errERPEnvironmentLookup
|
||||||
|
}
|
||||||
|
values, err := readERPEnvironmentFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func(name string) (string, bool) {
|
||||||
|
if value, exists := parent(name); exists {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
value, exists := values[name]
|
||||||
|
return value, exists
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readERPEnvironmentFile(path string) (map[string]string, error) {
|
||||||
|
values := make(map[string]string)
|
||||||
|
if path == "" {
|
||||||
|
return nil, errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("ERP .env file cannot be read")
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
contents, err := io.ReadAll(io.LimitReader(
|
||||||
|
file,
|
||||||
|
maximumERPEnvironmentFileBytes+1,
|
||||||
|
))
|
||||||
|
if err != nil || len(contents) > maximumERPEnvironmentFileBytes ||
|
||||||
|
!utf8.Valid(contents) || strings.IndexByte(string(contents), 0) >= 0 {
|
||||||
|
return nil, errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rawLine := range strings.Split(string(contents), "\n") {
|
||||||
|
line := strings.TrimSuffix(rawLine, "\r")
|
||||||
|
if len(line) > maximumERPEnvironmentLineBytes {
|
||||||
|
return nil, errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "export ") {
|
||||||
|
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||||
|
}
|
||||||
|
name, rawValue, found := strings.Cut(line, "=")
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if !found || !validEnvironmentName(name) {
|
||||||
|
return nil, errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
if !isERPEnvironmentName(name) {
|
||||||
|
if strings.HasPrefix(name, "CMROUBAO_") {
|
||||||
|
return nil, errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := values[name]; exists {
|
||||||
|
return nil, errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
value, err := dotenvValue(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
values[name] = value
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dotenvValue(rawValue string) (string, error) {
|
||||||
|
value := strings.TrimSpace(rawValue)
|
||||||
|
if value == "" {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
quote := value[0]
|
||||||
|
if quote == '\'' || quote == '"' {
|
||||||
|
if len(value) < 2 || value[len(value)-1] != quote {
|
||||||
|
return "", errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
return value[1 : len(value)-1], nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(value, "\"") || strings.HasSuffix(value, "'") {
|
||||||
|
return "", errERPEnvironmentFileInvalid
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isERPEnvironmentName(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case ShunyunbaoURLEnvironment,
|
||||||
|
ShunyunbaoUsernameEnvironment,
|
||||||
|
ShunyunbaoPasswordEnvironment:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEnvironmentName(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := 0; index < len(name); index++ {
|
||||||
|
character := name[index]
|
||||||
|
if (character >= 'A' && character <= 'Z') ||
|
||||||
|
(character >= '0' && character <= '9' && index > 0) ||
|
||||||
|
character == '_' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
|
||||||
|
path := writeERPEnvironmentFile(t, strings.Join([]string{
|
||||||
|
"OTHER_TOOL_TOKEN=ignored",
|
||||||
|
"CMROUBAO_SHUNYUNBAO_URL=https://erp.example.test",
|
||||||
|
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
||||||
|
"CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'",
|
||||||
|
}, "\n"))
|
||||||
|
|
||||||
|
lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithERPEnvironmentFile() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(lookup)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
||||||
|
cfg.ShunyunbaoUsername != "dotenv-user" ||
|
||||||
|
cfg.ShunyunbaoPassword != "dotenv password #1" {
|
||||||
|
t.Fatalf(
|
||||||
|
"ERP config = %#v",
|
||||||
|
struct {
|
||||||
|
URL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}{cfg.ShunyunbaoURL, cfg.ShunyunbaoUsername, cfg.ShunyunbaoPassword},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, exists := lookup("OTHER_TOOL_TOKEN"); exists {
|
||||||
|
t.Fatal("unapproved .env value was exposed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithERPEnvironmentFileProcessEnvironmentTakesPriority(t *testing.T) {
|
||||||
|
path := writeERPEnvironmentFile(t, strings.Join([]string{
|
||||||
|
"CMROUBAO_SHUNYUNBAO_URL=https://dotenv.example.test",
|
||||||
|
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
||||||
|
"CMROUBAO_SHUNYUNBAO_PASSWORD=dotenv-password",
|
||||||
|
}, "\n"))
|
||||||
|
process := map[string]string{
|
||||||
|
ShunyunbaoURLEnvironment: "https://process.example.test",
|
||||||
|
ShunyunbaoUsernameEnvironment: "process-user",
|
||||||
|
ShunyunbaoPasswordEnvironment: "process-password",
|
||||||
|
}
|
||||||
|
lookup, err := WithERPEnvironmentFile(path, lookupMap(process))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithERPEnvironmentFile() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(lookup)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ShunyunbaoURL != process[ShunyunbaoURLEnvironment] ||
|
||||||
|
cfg.ShunyunbaoUsername != process[ShunyunbaoUsernameEnvironment] ||
|
||||||
|
cfg.ShunyunbaoPassword != process[ShunyunbaoPasswordEnvironment] {
|
||||||
|
t.Fatalf("process values did not override .env values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithERPEnvironmentFileAllowsMissingFile(t *testing.T) {
|
||||||
|
lookup, err := WithERPEnvironmentFile(
|
||||||
|
filepath.Join(t.TempDir(), "missing.env"),
|
||||||
|
lookupMap(nil),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithERPEnvironmentFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := lookup(ShunyunbaoUsernameEnvironment); exists {
|
||||||
|
t.Fatal("missing file provided a value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithERPEnvironmentFileRejectsInvalidContentWithoutLeakingValues(t *testing.T) {
|
||||||
|
const secret = "must-not-appear-in-error"
|
||||||
|
testCases := map[string]string{
|
||||||
|
"invalid name": "not a name=" + secret,
|
||||||
|
"duplicate": strings.Join([]string{
|
||||||
|
"CMROUBAO_SHUNYUNBAO_USERNAME=" + secret,
|
||||||
|
"CMROUBAO_SHUNYUNBAO_USERNAME=another",
|
||||||
|
}, "\n"),
|
||||||
|
"unknown app config": "CMROUBAO_AUTH_PASSWORD=" + secret,
|
||||||
|
"unmatched quote": "CMROUBAO_SHUNYUNBAO_PASSWORD='" + secret,
|
||||||
|
"nul": "CMROUBAO_SHUNYUNBAO_PASSWORD=" + secret + "\x00",
|
||||||
|
}
|
||||||
|
for name, contents := range testCases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
path := writeERPEnvironmentFile(t, contents)
|
||||||
|
_, err := WithERPEnvironmentFile(path, lookupMap(nil))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("WithERPEnvironmentFile() error = nil")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatalf("error leaked .env content: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithERPEnvironmentFileRejectsOversizedFile(t *testing.T) {
|
||||||
|
path := writeERPEnvironmentFile(
|
||||||
|
t,
|
||||||
|
"CMROUBAO_SHUNYUNBAO_USERNAME="+
|
||||||
|
strings.Repeat("x", maximumERPEnvironmentFileBytes),
|
||||||
|
)
|
||||||
|
_, err := WithERPEnvironmentFile(path, lookupMap(nil))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("WithERPEnvironmentFile() error = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeERPEnvironmentFile(t *testing.T, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupMap(values map[string]string) LookupEnvironment {
|
||||||
|
return func(name string) (string, bool) {
|
||||||
|
value, exists := values[name]
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,8 +107,10 @@
|
|||||||
发起任意网络请求。候选图片使用鉴权、解码、大小/像素/哈希和脱敏受控上传。
|
发起任意网络请求。候选图片使用鉴权、解码、大小/像素/哈希和脱敏受控上传。
|
||||||
- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的
|
- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的
|
||||||
`@latest` 依赖。
|
`@latest` 依赖。
|
||||||
- 后端不得自动加载 `.env`、默认开启 CORS、使用包级数据库单例,或在库、handler、
|
- 后端默认不得加载 `.env`。唯一例外是 `cmd/api` 在读取配置前,以有界、allowlist 解析器
|
||||||
健康检查中调用 `log.Fatal`/`os.Exit`。
|
将 `backend-api/.env` 中三个 ERP 值作为进程环境变量的回退;不得修改全局环境、加载
|
||||||
|
`authctl`/数据库/HTTP/TLS 配置或接受任意路径。后端不得默认开启 CORS、使用包级数据库
|
||||||
|
单例,或在库、handler、健康检查中调用 `log.Fatal`/`os.Exit`。
|
||||||
- `http.Server` 必须配置 read-header/read/write/idle/header 上限和有界关闭;
|
- `http.Server` 必须配置 read-header/read/write/idle/header 上限和有界关闭;
|
||||||
recovery 不得把 Authorization、Cookie、panic 或请求正文写普通日志。
|
recovery 不得把 Authorization、Cookie、panic 或请求正文写普通日志。
|
||||||
- SQLite 数据文件必须位于被忽略目录,启用 foreign keys、有限 busy timeout 和
|
- SQLite 数据文件必须位于被忽略目录,启用 foreign keys、有限 busy timeout 和
|
||||||
|
|||||||
+6
-3
@@ -209,7 +209,7 @@ Cookie。它们只管理单个后端进程内的顺运宝会话,不创建货
|
|||||||
| `GET /api/v1/erp-session` | 返回 `configured`、`authenticated`、`captcha_ready` 三个布尔状态。 |
|
| `GET /api/v1/erp-session` | 返回 `configured`、`authenticated`、`captcha_ready` 三个布尔状态。 |
|
||||||
| `POST /api/v1/erp-session/captcha` | 同一服务端 Cookie jar 获取一次验证码,返回短期 `captcha_ticket` 和仅供当前 ADMIN 读取图片的 `captcha_url`。 |
|
| `POST /api/v1/erp-session/captcha` | 同一服务端 Cookie jar 获取一次验证码,返回短期 `captcha_ticket` 和仅供当前 ADMIN 读取图片的 `captcha_url`。 |
|
||||||
| `GET /api/v1/erp-session/captcha/{ticket}` | 返回 `image/*` 验证码,始终 `Cache-Control: no-store`。ticket 过期或不匹配返回稳定匿名错误。 |
|
| `GET /api/v1/erp-session/captcha/{ticket}` | 返回 `image/*` 验证码,始终 `Cache-Control: no-store`。ticket 过期或不匹配返回稳定匿名错误。 |
|
||||||
| `POST /api/v1/erp-session/login` | 仅接受 `{"captcha_ticket":"...","captcha_code":"..."}`,使用启动环境中的账号/密码完成登录和用户校验。 |
|
| `POST /api/v1/erp-session/login` | 仅接受 `{"captcha_ticket":"...","captcha_code":"..."}`,使用受控本地配置中的账号/密码完成登录和用户校验。 |
|
||||||
|
|
||||||
成功状态响应不包含 ticket,验证码接口以外也不会返回验证码图片。稳定错误为
|
成功状态响应不包含 ticket,验证码接口以外也不会返回验证码图片。稳定错误为
|
||||||
`ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_CAPTCHA_INVALID`、
|
`ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_CAPTCHA_INVALID`、
|
||||||
@@ -217,14 +217,17 @@ Cookie。它们只管理单个后端进程内的顺运宝会话,不创建货
|
|||||||
正文或凭证。管理员 Web 的等价入口为 `/erp`、`/erp/captcha` 和 `/erp/login`,表单没有
|
正文或凭证。管理员 Web 的等价入口为 `/erp`、`/erp/captcha` 和 `/erp/login`,表单没有
|
||||||
账号或密码字段。
|
账号或密码字段。
|
||||||
|
|
||||||
启动环境变量:
|
ERP 配置来源:
|
||||||
|
|
||||||
- `CMROUBAO_SHUNYUNBAO_URL`:默认 `https://www.shunyunbaoerp.com`;只接受无路径、
|
- `CMROUBAO_SHUNYUNBAO_URL`:默认 `https://www.shunyunbaoerp.com`;只接受无路径、
|
||||||
userinfo、query 或 fragment 的 HTTPS origin。
|
userinfo、query 或 fragment 的 HTTPS origin。
|
||||||
- `CMROUBAO_SHUNYUNBAO_USERNAME` 与 `CMROUBAO_SHUNYUNBAO_PASSWORD`:必须同时设置;
|
- `CMROUBAO_SHUNYUNBAO_USERNAME` 与 `CMROUBAO_SHUNYUNBAO_PASSWORD`:必须同时设置;
|
||||||
为空时连接页明确显示未配置,服务进程仍可用于其他本地功能。
|
为空时连接页明确显示未配置,服务进程仍可用于其他本地功能。
|
||||||
|
- `cmd/api` 只把标准工作目录 `backend-api/.env` 中的上述三个值作为回退。系统环境变量
|
||||||
|
优先;文件缺失不报错。该解析器不修改全局环境,不加载数据库、HTTP、TLS 或 authctl
|
||||||
|
密码,也不支持变量展开、命令执行或任意路径。
|
||||||
|
|
||||||
会话不写 SQLite、不加载 `.env`、不使用 OCR 或 Redis;服务重启后必须重新人工登录。
|
会话不写 SQLite、不使用 OCR 或 Redis;服务重启后必须重新人工登录。
|
||||||
|
|
||||||
### `POST /api/v1/freight-syncs`
|
### `POST /api/v1/freight-syncs`
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-29
|
- 日期:2026-07-29
|
||||||
- 阶段:T-229 待增加受控 `backend-api/.env` ERP 凭证加载;随后进行人工验证码登录 smoke
|
- 阶段:T-229 已完成受控 `backend-api/.env` ERP 凭证加载;随后进行人工验证码登录 smoke
|
||||||
- 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 源码
|
||||||
@@ -20,8 +20,9 @@
|
|||||||
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
|
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
|
||||||
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226
|
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226
|
||||||
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
||||||
`/erp` 页面/API;账号密码仅由 `CMROUBAO_SHUNYUNBAO_*` 启动环境读取,重启后需人工
|
`/erp` 页面/API;账号密码可由 `backend-api/.env` 的受控 `CMROUBAO_SHUNYUNBAO_*`
|
||||||
重新登录。T-227 已将其作为 `FreightSource`,查询先校验会话、再执行有界分页/详情
|
回退提供,进程环境变量优先,重启后需人工重新登录。T-227 已将其作为 `FreightSource`,
|
||||||
|
查询先校验会话、再执行有界分页/详情
|
||||||
批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key;
|
批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key;
|
||||||
未访问真实 ERP。
|
未访问真实 ERP。
|
||||||
- ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,source 单窗
|
- ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,source 单窗
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||||
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
||||||
- 后端测试:T-226 至 T-228 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
|
- 后端测试:T-226 至 T-229 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
|
||||||
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
|
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
|
||||||
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
||||||
验证也通过,均未访问真实 ERP;
|
验证也通过,均未访问真实 ERP;
|
||||||
@@ -106,7 +107,9 @@
|
|||||||
付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。
|
付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。
|
||||||
- T-220 至 T-228 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和
|
- T-220 至 T-228 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和
|
||||||
缺图边界;Go API 以受锁内存会话完成受控验证码登录,并直接按完整单号或日期范围查询
|
缺图边界;Go API 以受锁内存会话完成受控验证码登录,并直接按完整单号或日期范围查询
|
||||||
allowlist schema。旧 Python Connector、8091 端口和共享服务密钥已删除;未访问线上 ERP。
|
allowlist schema。T-229 仅允许 API 从当前目录 `.env` 回退读取三个 ERP 配置,且不会
|
||||||
|
改写进程环境或加载其他配置。旧 Python Connector、8091 端口和共享服务密钥已删除;
|
||||||
|
未访问线上 ERP。
|
||||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||||
@@ -183,6 +186,7 @@
|
|||||||
| `docs/tasks/T-226.md` | DONE | Go ERP 会话、验证码登录与 Admin 连接页 |
|
| `docs/tasks/T-226.md` | DONE | Go ERP 会话、验证码登录与 Admin 连接页 |
|
||||||
| `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 |
|
| `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 |
|
||||||
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
|
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
|
||||||
|
| `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 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 基线 |
|
||||||
@@ -194,12 +198,11 @@
|
|||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成: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-228 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
- 已完成:另含 T-220 至 T-229 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
||||||
直连协议、人工验证码会话、直连 `FreightSource` 和旧 Connector 清理。
|
直连协议、人工验证码会话、直连 `FreightSource`、旧 Connector 清理和受控本地凭证加载。
|
||||||
- 进行中:无。
|
- 进行中:无。
|
||||||
- 下一步:T-229 让本地单机从受控 `backend-api/.env` 读取 ERP 凭证;随后确认开放 API、
|
- 下一步:确认开放 API、数据使用权限,并由人员在 `/erp` 完成验证码登录后以受控单号
|
||||||
数据使用权限,并由人员在 `/erp` 完成验证码登录后以受控单号执行一次不记录订单内容的
|
执行一次不记录订单内容的 smoke。
|
||||||
smoke。
|
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -4,7 +4,7 @@ title: 从受控 .env 加载 ERP 凭证
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-228
|
- T-228
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-29
|
created: 2026-07-29
|
||||||
context_ref: 13a1355
|
context_ref: 13a1355
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -13,6 +13,7 @@ write_paths:
|
|||||||
- docs/api.md
|
- docs/api.md
|
||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
- docs/05-coding-rules.md
|
- docs/05-coding-rules.md
|
||||||
|
- backend-api/.gitignore
|
||||||
- backend-api/.env.example
|
- backend-api/.env.example
|
||||||
- backend-api/README.md
|
- backend-api/README.md
|
||||||
- backend-api/SCAFFOLD.md
|
- backend-api/SCAFFOLD.md
|
||||||
@@ -44,10 +45,10 @@ T-226 至 T-228 要求 ERP 账号和密码只在后端进程内使用,但当
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] API 从 `backend-api/.env` 读取完整 ERP 凭证;系统环境变量覆盖同名 `.env` 值。
|
- [x] API 从 `backend-api/.env` 读取完整 ERP 凭证;系统环境变量覆盖同名 `.env` 值。
|
||||||
- [ ] 文件缺失、格式错误、重复键、未知受控键和敏感值均有测试;错误和日志不泄露密码。
|
- [x] 文件缺失、格式错误、重复键、未知受控键和敏感值均有测试;错误和日志不泄露密码。
|
||||||
- [ ] `.env` 始终被 Git 忽略,`.env.example` 不含真实凭证;Admin 页面仍没有账号/密码字段。
|
- [x] `.env` 始终被 Git 忽略,`.env.example` 不含真实凭证;Admin 页面仍没有账号/密码字段。
|
||||||
- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和 API 构建通过。
|
- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和 API 构建通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -57,4 +58,7 @@ T-226 至 T-228 要求 ERP 账号和密码只在后端进程内使用,但当
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 2026-07-29:创建任务,等待文档提交后实现。
|
- 2026-07-29:创建任务并以提交 `d75a7d8` 固定范围。
|
||||||
|
- 2026-07-29:实现仅限 `cmd/api` 的受控 `.env` 回退;新增 parser/优先级/敏感错误和 API
|
||||||
|
入口测试。验证:`GOTOOLCHAIN=local go test ./...`、`go test -race ./...`、`go vet ./...`
|
||||||
|
及三个 Go 入口构建通过;未访问真实 ERP。
|
||||||
|
|||||||
Reference in New Issue
Block a user