feat(t229): load ERP credentials from dotenv

This commit is contained in:
QiuSW
2026-07-29 10:25:56 +08:00
parent d75a7d8dc0
commit f0fbdbaddc
12 changed files with 396 additions and 26 deletions
+4
View File
@@ -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
View File
@@ -1,5 +1,6 @@
.env .env
.env.* .env.*
!.env.example
bin/ bin/
var/ var/
*.db *.db
+22 -2
View File
@@ -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` 是受支持的本地停用/恢复入口;禁用会让该主体的
+4 -2
View File
@@ -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.
+15 -1
View File
@@ -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,
+35
View File
@@ -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{})
+146
View File
@@ -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
}
+136
View File
@@ -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
}
}
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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。