淘宝会在登录仍然有效的情况下停止下发视频资源:不跳登录页、不出验证码, 详情页里就是没有 mp4 地址。原实现识别不了,把它当成「这个同款没视频」, 继续跑满 20 个同款加重风控,最后写 video_status='none', 而断点规则会永久跳过 none,导致账号恢复后这批商品也不再重试。 与参考实现对齐: - 详情页等待 4 秒改为 8 秒,提为 detail_wait_seconds - 每个同款详情页前做一次 my_itaobao 深度守卫,在两次详情页之间插入 正常页面访问;守卫等待提为 guard_wait_seconds - 详情页后补一次 Cookie 级快速守卫,复用 judgeLogin - 淘宝首页导航等待 3 秒改为 6 秒。签名请求在该页面上下文里 fetch, 页面没加载完就发可能带不上 Cookie - 下载失败重试,5xx 与连接错误重试 download_retries 次, 4xx 和 ffprobe 校验失败不重试 - 每次下载尝试各有独立的 180 秒超时。此前超时套在整个重试循环外面, 大视频首次跑到一半失败后剩余预算不足,重试等于不生效 新增风控降级识别:连续 risk_empty_threshold 个同款打开成功但取不到视频时, 判为疑似风控,中断整批任务并保持商品 pending,绝不写入 none。 停止原因通过 stopReason 枚举传给前端,不依赖中文文本分支。 数据订正提供显式按钮,不写进 migrations。刻意不做日期过滤: synced_at 记录的是商品数据何时从货憨憨拉取,与 video_status 何时被写成 none 无关,每次「下载数据」都会把它刷成当天。某商品是否需要重做的长期 答案来自货憨憨每次全量拉取覆盖的 video_diagnosis,不来自本地时间戳。 淘宝与 Chrome 相关逻辑由假实现覆盖,真机验证尚未进行。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
473 lines
17 KiB
Go
473 lines
17 KiB
Go
// Package config 负责读写和校验程序的参数设置。
|
||
//
|
||
// 配置文件是程序目录下的 config.yaml,用 YAML 而不是 JSON,
|
||
// 是因为 YAML 能写注释——很多坑(比如密码必须加引号)只有写在
|
||
// 字段旁边,同事才不会踩。
|
||
//
|
||
// 设计约定(改代码前请先读):
|
||
//
|
||
// - config.yaml 含密码,已在 .gitignore 里,绝不能提交进 Git。
|
||
// 进 Git 的是 config.example.yaml,那份不含真实凭据。
|
||
//
|
||
// - 淘宝没有账号密码字段,将来也不要加。淘宝登录必须由使用者
|
||
// 在专属 Chrome 里手动完成,程序只保存浏览器路径,不碰凭据。
|
||
// 这是 AGENTS.md 里的红线。
|
||
//
|
||
// - 保存配置时用 Render() 按固定模板重新渲染,不要用 yaml.Marshal,
|
||
// 否则注释会被全部冲掉。
|
||
//
|
||
// - 新增一个字段要改四处:结构体、Default()、Validate()、
|
||
// Render() 里的模板。漏掉任何一处,单元测试会失败。
|
||
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// Config 是程序的全部参数设置,对应界面上的「参数设置」页。
|
||
//
|
||
// yaml tag 就是配置文件里的键名。改名会导致老配置读不出来,
|
||
// 所以除非有充分理由,不要改已有字段的 tag。
|
||
type Config struct {
|
||
Huohanhan HuohanhanConfig `yaml:"huohanhan" json:"huohanhan"`
|
||
Taobao TaobaoConfig `yaml:"taobao" json:"taobao"`
|
||
Download DownloadConfig `yaml:"download" json:"download"`
|
||
}
|
||
|
||
// HuohanhanConfig 是货憨憨 ERP 的连接信息。
|
||
type HuohanhanConfig struct {
|
||
// BaseURL 是货憨憨网站地址,不带结尾的斜杠。
|
||
BaseURL string `yaml:"base_url" json:"baseUrl"`
|
||
// Account 是登录账号。
|
||
Account string `yaml:"account" json:"account"`
|
||
// Password 是登录密码。属于凭据,禁止写入日志、工单和 Wiki。
|
||
Password string `yaml:"password" json:"password"`
|
||
// OCRURL 是识别登录验证码的外部服务地址。
|
||
OCRURL string `yaml:"ocr_url" json:"ocrUrl"`
|
||
}
|
||
|
||
// TaobaoConfig 是淘宝专属浏览器的设置。
|
||
//
|
||
// 这里故意没有账号和密码字段:淘宝登录由使用者手动完成,
|
||
// 登录态保存在 Chrome 用户数据目录里,程序不读取也不保存。
|
||
type TaobaoConfig struct {
|
||
// ChromePath 是 chrome.exe 的完整路径,注意是文件不是目录。
|
||
ChromePath string `yaml:"chrome_path" json:"chromePath"`
|
||
// UserDataDir 是专属 Chrome 的用户数据目录,淘宝登录态存在这里。
|
||
UserDataDir string `yaml:"user_data_dir" json:"userDataDir"`
|
||
// DebugPortStart / DebugPortEnd 是分配调试端口的范围。
|
||
DebugPortStart int `yaml:"debug_port_start" json:"debugPortStart"`
|
||
DebugPortEnd int `yaml:"debug_port_end" json:"debugPortEnd"`
|
||
}
|
||
|
||
// DownloadConfig 是下载与任务参数。
|
||
type DownloadConfig struct {
|
||
// VideoDir 是下载的视频保存目录。
|
||
VideoDir string `yaml:"video_dir" json:"videoDir"`
|
||
// MaxVideosPerProduct 是每个商品最多下载几个视频。
|
||
MaxVideosPerProduct int `yaml:"max_videos_per_product" json:"maxVideosPerProduct"`
|
||
// SearchTopN 是图搜结果里取前几个同款去找视频。
|
||
SearchTopN int `yaml:"search_top_n" json:"searchTopN"`
|
||
// Concurrency 是同时下载几个视频。
|
||
// 调大会提高淘宝风控概率,没有实测结论前不要超过 3。
|
||
Concurrency int `yaml:"concurrency" json:"concurrency"`
|
||
// WaitSecondsMin / WaitSecondsMax 是处理完一个商品后随机等待的秒数区间。
|
||
WaitSecondsMin float64 `yaml:"wait_seconds_min" json:"waitSecondsMin"`
|
||
WaitSecondsMax float64 `yaml:"wait_seconds_max" json:"waitSecondsMax"`
|
||
DetailWaitSeconds float64 `yaml:"detail_wait_seconds" json:"detailWaitSeconds"`
|
||
GuardWaitSeconds float64 `yaml:"guard_wait_seconds" json:"guardWaitSeconds"`
|
||
RiskEmptyThreshold int `yaml:"risk_empty_threshold" json:"riskEmptyThreshold"`
|
||
DownloadRetries int `yaml:"download_retries" json:"downloadRetries"`
|
||
}
|
||
|
||
// Default 返回一份可以直接使用的默认配置。
|
||
//
|
||
// 账号和密码故意留空:程序不内置任何凭据,必须由使用者自己填。
|
||
func Default() Config {
|
||
return Config{
|
||
Huohanhan: HuohanhanConfig{
|
||
BaseURL: "https://www.huohanhan.com",
|
||
Account: "",
|
||
Password: "",
|
||
OCRURL: "https://ocr.ilapage.cn/ocr",
|
||
},
|
||
Taobao: TaobaoConfig{
|
||
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
||
UserDataDir: DefaultChromeUserDataDir(),
|
||
DebugPortStart: 19666,
|
||
DebugPortEnd: 19765,
|
||
},
|
||
Download: DownloadConfig{
|
||
VideoDir: DefaultVideoDir(),
|
||
MaxVideosPerProduct: 3,
|
||
SearchTopN: 20,
|
||
Concurrency: 2,
|
||
WaitSecondsMin: 2,
|
||
WaitSecondsMax: 4,
|
||
DetailWaitSeconds: 8,
|
||
GuardWaitSeconds: 3,
|
||
RiskEmptyThreshold: 8,
|
||
DownloadRetries: 3,
|
||
},
|
||
}
|
||
}
|
||
|
||
// DefaultChromeUserDataDir 返回专属 Chrome 用户数据目录的默认位置。
|
||
//
|
||
// 这个路径沿用迁移前 Python 版本的目录,改动它会导致同事需要重新
|
||
// 扫码登录淘宝,所以不要随手改。
|
||
func DefaultChromeUserDataDir() string {
|
||
base := os.Getenv("LOCALAPPDATA")
|
||
if base == "" {
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return filepath.Join(".", "淘宝浏览器", "默认账号")
|
||
}
|
||
base = home
|
||
}
|
||
return filepath.Join(base, "电商视频自动下载工具", "淘宝浏览器", "默认账号")
|
||
}
|
||
|
||
// DataRoot 返回程序存放数据的根目录。
|
||
//
|
||
// 规则很简单:**配置文件在哪,数据就在哪**。
|
||
//
|
||
// 这样两种用法都对:
|
||
// - 打包后双击 exe:config.yaml 在 exe 旁边,数据也在 exe 旁边
|
||
// - 开发模式:config.yaml 在项目根目录(wails dev 的工作目录),
|
||
// 数据也落在项目根目录
|
||
//
|
||
// 不能直接用 exe 所在目录:wails dev 跑的是 buildin\cmsp-dev.exe,
|
||
// 数据会落进 buildin,而 `wails build -clean` 会清空那个目录,
|
||
// 已经下载好的视频会被一起删掉。
|
||
func DataRoot() string {
|
||
return filepath.Dir(DefaultPath())
|
||
}
|
||
|
||
// DefaultVideoDir 返回视频默认保存目录:数据根目录下的「运行数据/视频」。
|
||
func DefaultVideoDir() string {
|
||
return filepath.Join(DataRoot(), "运行数据", "视频")
|
||
}
|
||
|
||
// Validate 检查配置是否可用。返回的错误信息会直接显示给使用者,
|
||
// 所以要写成一句能看懂的中文,并说明允许范围。
|
||
//
|
||
// 这里不校验账号密码对不对,那要等真正登录时才知道。
|
||
// 这里只保证「格式上能用」。
|
||
func (c Config) Validate() error {
|
||
h := c.Huohanhan
|
||
if strings.TrimSpace(h.BaseURL) == "" {
|
||
return fmt.Errorf("货憨憨网址不能为空")
|
||
}
|
||
if !strings.HasPrefix(h.BaseURL, "http://") && !strings.HasPrefix(h.BaseURL, "https://") {
|
||
return fmt.Errorf("货憨憨网址必须以 http:// 或 https:// 开头")
|
||
}
|
||
if strings.TrimSpace(h.OCRURL) == "" {
|
||
return fmt.Errorf("OCR 识别服务地址不能为空")
|
||
}
|
||
|
||
t := c.Taobao
|
||
if strings.TrimSpace(t.ChromePath) == "" {
|
||
return fmt.Errorf("Chrome 可执行文件路径不能为空")
|
||
}
|
||
if strings.TrimSpace(t.UserDataDir) == "" {
|
||
return fmt.Errorf("Chrome 用户数据目录不能为空")
|
||
}
|
||
if err := checkPort(t.DebugPortStart, "调试端口起始"); err != nil {
|
||
return err
|
||
}
|
||
if err := checkPort(t.DebugPortEnd, "调试端口结束"); err != nil {
|
||
return err
|
||
}
|
||
if t.DebugPortEnd < t.DebugPortStart {
|
||
return fmt.Errorf("调试端口结束不能小于起始端口")
|
||
}
|
||
|
||
d := c.Download
|
||
if strings.TrimSpace(d.VideoDir) == "" {
|
||
return fmt.Errorf("视频保存目录不能为空")
|
||
}
|
||
if err := checkIntRange(d.MaxVideosPerProduct, 1, 10, "每个商品最多下载视频数"); err != nil {
|
||
return err
|
||
}
|
||
if err := checkIntRange(d.SearchTopN, 1, 60, "图搜取前 N 个同款"); err != nil {
|
||
return err
|
||
}
|
||
if err := checkIntRange(d.Concurrency, 1, 8, "下载并发数"); err != nil {
|
||
return err
|
||
}
|
||
if d.WaitSecondsMin < 0 || d.WaitSecondsMin > 60 {
|
||
return fmt.Errorf("商品间最短等待秒数不合法,允许范围 0—60")
|
||
}
|
||
if d.WaitSecondsMax < 0 || d.WaitSecondsMax > 120 {
|
||
return fmt.Errorf("商品间最长等待秒数不合法,允许范围 0—120")
|
||
}
|
||
if d.WaitSecondsMax < d.WaitSecondsMin {
|
||
return fmt.Errorf("商品间最长等待秒数不能小于最短等待秒数")
|
||
}
|
||
if d.DetailWaitSeconds < 3 || d.DetailWaitSeconds > 30 {
|
||
return fmt.Errorf("详情页加载等待秒数不合法,允许范围 3—30")
|
||
}
|
||
if d.GuardWaitSeconds < 1 || d.GuardWaitSeconds > 15 {
|
||
return fmt.Errorf("登录守卫等待秒数不合法,允许范围 1—15")
|
||
}
|
||
if err := checkIntRange(d.RiskEmptyThreshold, 3, 50, "疑似风控连续空结果阈值"); err != nil {
|
||
return err
|
||
}
|
||
if err := checkIntRange(d.DownloadRetries, 0, 5, "下载重试次数"); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func checkPort(port int, name string) error {
|
||
if port < 1024 || port > 65535 {
|
||
return fmt.Errorf("%s不合法,允许范围 1024—65535", name)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func checkIntRange(value, min, max int, name string) error {
|
||
if value < min || value > max {
|
||
return fmt.Errorf("%s不合法,允许范围 %d—%d", name, min, max)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Desensitized 返回一份把密码换成固定占位符的副本。
|
||
//
|
||
// 任何要写日志、写工单,或者传给不需要密码的地方的场景,都用这个方法,
|
||
// 不要直接传 Config。
|
||
func (c Config) Desensitized() Config {
|
||
copied := c
|
||
if copied.Huohanhan.Password != "" {
|
||
copied.Huohanhan.Password = "******"
|
||
}
|
||
return copied
|
||
}
|
||
|
||
// Load 从 path 读取配置。
|
||
//
|
||
// 文件不存在时返回默认配置而不是错误——第一次启动本来就没有配置文件,
|
||
// 这时应该让程序正常打开、显示默认值,由使用者去填。
|
||
func Load(path string) (Config, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if os.IsNotExist(err) {
|
||
return Default(), nil
|
||
}
|
||
if err != nil {
|
||
return Config{}, fmt.Errorf("读取配置文件失败:%w", err)
|
||
}
|
||
|
||
// 先铺上默认值再解析,这样老配置文件缺少新字段时,
|
||
// 新字段会保留默认值而不是变成零值。
|
||
cfg := Default()
|
||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||
return Config{}, fmt.Errorf("配置文件不是有效的 YAML:%w", err)
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
// Save 校验并写入配置。
|
||
//
|
||
// 先校验再写,避免把一份用不了的配置存进去。
|
||
// 写入的是 Render() 渲染的带注释版本,不是 yaml.Marshal 的裸数据。
|
||
func Save(path string, cfg Config) error {
|
||
if err := cfg.Validate(); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return fmt.Errorf("创建配置目录失败:%w", err)
|
||
}
|
||
// 权限 0600:只有当前用户能读。文件里有密码。
|
||
if err := os.WriteFile(path, []byte(cfg.Render()), 0o600); err != nil {
|
||
return fmt.Errorf("写入配置文件失败:%w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Render 按固定模板把配置渲染成带注释的 YAML。
|
||
//
|
||
// 为什么不用 yaml.Marshal:那样会把注释全部丢掉,同事下次打开
|
||
// config.yaml 就只剩一堆键值对,不知道每项什么意思、有什么坑。
|
||
//
|
||
// 新增字段时记得在这里的模板中也加上,并补一句说明。
|
||
func (c Config) Render() string {
|
||
return fmt.Sprintf(`# cmsp 本机配置
|
||
#
|
||
# 本文件由「参数设置」页保存时自动重写,注释会保留。
|
||
# 也可以直接用记事本改,改完重启程序生效。
|
||
#
|
||
# [必须] 本文件含密码,已在 .gitignore 里,绝不能提交进 Git,
|
||
# 也不要打包发给别人、截图或粘贴到工单和日志里。
|
||
# [必须] 密码要用双引号包起来。纯数字密码不加引号会被 YAML 当成
|
||
# 整数,读取时直接报错,前导 0 也会丢。
|
||
|
||
huohanhan:
|
||
# 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。
|
||
base_url: %s
|
||
|
||
# 登录账号。
|
||
account: %s
|
||
|
||
# [必须] 登录密码,加双引号。
|
||
password: %s
|
||
|
||
# 登录验证码的自动识别服务。
|
||
# [注意] 验证码图片会被发送到这个地址,换成别人的服务前先评估一下。
|
||
ocr_url: %s
|
||
|
||
taobao:
|
||
# [必须] 这里没有淘宝账号和密码,将来也不要加。
|
||
# 淘宝登录必须由你在程序打开的专属 Chrome 里手动扫码完成,
|
||
# 登录态保存在下面的 user_data_dir 里,程序不读取也不保存。
|
||
# 程序不会代填密码,也不会绕过验证码或滑块。
|
||
|
||
# chrome.exe 的完整路径。注意是文件不是目录。
|
||
chrome_path: %s
|
||
|
||
# 专属 Chrome 的用户数据目录,淘宝登录态就存在这里。
|
||
# [注意] 改了这个路径就要重新扫码登录一次,不要随手改。
|
||
# 这个目录不要提交、打包或共享。
|
||
user_data_dir: %s
|
||
|
||
# 给专属 Chrome 分配调试端口的范围。端口被占用时会往后找。
|
||
debug_port_start: %d
|
||
debug_port_end: %d
|
||
|
||
download:
|
||
# 下载的视频保存到哪个目录。
|
||
video_dir: %s
|
||
|
||
# 每个商品最多下载几个视频,允许 1—10。
|
||
max_videos_per_product: %d
|
||
|
||
# 图搜结果里取前几个同款去找视频,允许 1—60。取太多会明显变慢。
|
||
search_top_n: %d
|
||
|
||
# 同时下载几个视频,允许 1—8。
|
||
# [注意] 调大会提高淘宝风控概率,没有实测结论前不要超过 3。
|
||
concurrency: %d
|
||
|
||
# 处理完一个商品后随机等待的秒数区间,用来降低被风控的概率。
|
||
# 改小了跑得快但更容易被拦,不建议低于 2 秒。
|
||
wait_seconds_min: %s
|
||
wait_seconds_max: %s
|
||
|
||
# 淘宝详情页视频为异步加载;调小会漏视频,不建议低于 8 秒。
|
||
detail_wait_seconds: %s
|
||
|
||
# 每个同款详情页前访问「我的淘宝」的深度登录守卫等待秒数。
|
||
guard_wait_seconds: %s
|
||
|
||
# 连续多少个正常打开却没有视频的详情页时,判为疑似风控并停止任务。
|
||
risk_empty_threshold: %d
|
||
|
||
# 网络层下载失败后的重试次数;HTTP 4xx 和 ffprobe 校验失败不会重试。
|
||
download_retries: %d
|
||
`,
|
||
yamlString(c.Huohanhan.BaseURL),
|
||
yamlString(c.Huohanhan.Account),
|
||
quoted(c.Huohanhan.Password),
|
||
yamlString(c.Huohanhan.OCRURL),
|
||
quoted(c.Taobao.ChromePath),
|
||
quoted(c.Taobao.UserDataDir),
|
||
c.Taobao.DebugPortStart,
|
||
c.Taobao.DebugPortEnd,
|
||
quoted(c.Download.VideoDir),
|
||
c.Download.MaxVideosPerProduct,
|
||
c.Download.SearchTopN,
|
||
c.Download.Concurrency,
|
||
trimFloat(c.Download.WaitSecondsMin),
|
||
trimFloat(c.Download.WaitSecondsMax),
|
||
trimFloat(c.Download.DetailWaitSeconds),
|
||
trimFloat(c.Download.GuardWaitSeconds),
|
||
c.Download.RiskEmptyThreshold,
|
||
c.Download.DownloadRetries,
|
||
)
|
||
}
|
||
|
||
// quoted 把值渲染成带双引号的 YAML 字符串。
|
||
//
|
||
// Windows 路径里有反斜杠和空格,密码可能是纯数字或含特殊字符,
|
||
// 这些都必须加引号,否则 YAML 解析会出错或类型不对。
|
||
func quoted(v string) string {
|
||
// YAML 双引号字符串里,反斜杠和双引号要转义。
|
||
escaped := strings.ReplaceAll(v, `\`, `\\`)
|
||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||
return `"` + escaped + `"`
|
||
}
|
||
|
||
// yamlString 渲染普通字符串。空值写成一对空引号,避免出现裸的冒号后什么都没有。
|
||
func yamlString(v string) string {
|
||
if strings.TrimSpace(v) == "" {
|
||
return `""`
|
||
}
|
||
// 含特殊字符时一律加引号,省得判断哪些安全。
|
||
if strings.ContainsAny(v, `:#{}[],&*?|<>=!%@\"' `) {
|
||
return quoted(v)
|
||
}
|
||
return v
|
||
}
|
||
|
||
// trimFloat 把 2.0 渲染成 2,把 2.5 保留成 2.5,让配置文件更好看。
|
||
func trimFloat(v float64) string {
|
||
s := fmt.Sprintf("%.2f", v)
|
||
s = strings.TrimRight(s, "0")
|
||
return strings.TrimSuffix(s, ".")
|
||
}
|
||
|
||
// DefaultPath 返回配置文件的默认位置。
|
||
//
|
||
// 放在程序目录而不是系统目录,是为了让同事能直接看到和备份它。
|
||
//
|
||
// 查找顺序(这个顺序是为了同时照顾两种用法,改之前先读完):
|
||
//
|
||
// 1. exe 旁边已有 config.yaml → 用它。这是同事双击 exe 的正常情况。
|
||
// 2. 当前工作目录已有 config.yaml → 用它。这是开发模式的情况:
|
||
// `wails dev` 跑的是 build\bin\cmsp-dev.exe,按 exe 目录算会把配置
|
||
// 写到 build\bin\ 里,开发的人在项目根目录怎么找都找不到,
|
||
// 还以为保存没生效。
|
||
// 3. 两个都没有 → 新建在 exe 旁边。
|
||
//
|
||
// 换句话说:已经存在的配置优先,谁都不存在时才按 exe 目录建。
|
||
func DefaultPath() string {
|
||
return resolvePath("config.yaml")
|
||
}
|
||
|
||
// resolvePath 按「exe 目录 → 工作目录 → exe 目录(兜底)」找一个文件。
|
||
// 配置文件和数据库都用这套规则,行为保持一致。
|
||
func resolvePath(name string) string {
|
||
exeDir := ""
|
||
if exe, err := os.Executable(); err == nil {
|
||
exeDir = filepath.Dir(exe)
|
||
if candidate := filepath.Join(exeDir, name); fileExists(candidate) {
|
||
return candidate
|
||
}
|
||
}
|
||
if wd, err := os.Getwd(); err == nil {
|
||
if candidate := filepath.Join(wd, name); fileExists(candidate) {
|
||
return candidate
|
||
}
|
||
}
|
||
if exeDir != "" {
|
||
return filepath.Join(exeDir, name)
|
||
}
|
||
return name
|
||
}
|
||
|
||
func fileExists(path string) bool {
|
||
info, err := os.Stat(path)
|
||
return err == nil && !info.IsDir()
|
||
}
|
||
|
||
// ResolveDataPath 供其它包复用同一套查找规则,例如数据库文件。
|
||
func ResolveDataPath(name string) string {
|
||
return resolvePath(name)
|
||
}
|