-
存客宝获客计划添加的客户信息,来自链接卡若留资
+
diff --git a/soul-api/.dockerignore b/soul-api/.dockerignore
index f69dfd41..c2a12be4 100644
--- a/soul-api/.dockerignore
+++ b/soul-api/.dockerignore
@@ -2,16 +2,16 @@
.git
.gitignore
-# 本地开发(部署时 .env.development / .env.production 需打包进镜像)
+# 本地开发(部署时环境文件需进构建上下文,由 Dockerfile COPY ${ENV_FILE} → /app/.env)
.env
.env.*.local
*.local
!.env.development
!.env.production
+!.env
# 构建产物
-soul-api
-soul-api-*
+# 注意:不可忽略 soul-api —— Dockerfile.local 需 COPY 本地交叉编译的二进制;多阶段 Dockerfile 内 go build 会覆盖
*.exe
__pycache__
*.pyc
diff --git a/soul-api/devloy.py b/soul-api/devloy.py
index fead3825..c2aa4c3e 100644
--- a/soul-api/devloy.py
+++ b/soul-api/devloy.py
@@ -3,10 +3,18 @@
"""
soul-api 一键部署到宝塔【测试环境】
+打包原则:优先使用本地已有资源,不边打包边下载(省时)。
+- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
+- 首次部署前请本地先拉好:alpine:3.19、redis:7-alpine,后续一律用本地缓存
+
两种模式均部署到测试环境 /www/wwwroot/self/soul-dev:
- binary:Go 二进制 + 宝塔 soulDev 项目,用 .env.development
-- docker:Docker 镜像 + 蓝绿无缝切换,用 .env.development 打包进镜像
+- docker(默认):本地 go build → Dockerfile.local 打镜像 → 蓝绿无缝切换
+ - 镜像内包含:二进制、soul-api/certs/ → /app/certs/、选定环境文件 → /app/.env
+ - 环境文件:--env-file 或 DOCKER_ENV_FILE,否则自动 .env.development > .env.production > .env(.dockerignore 已放行)
+ - 不加 --docker-in-go 时:本地 Go 编译 + 本地 base 镜像,不联网
+ - 加 --docker-in-go 时:在 Docker 内用 golang 镜像编译(需本地已有 golang:1.25)
环境变量:DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST 等
"""
@@ -117,7 +125,7 @@ def run_build(root):
# ==================== 打包 ====================
-DEPLOY_PORT = 8081
+DEPLOY_PORT = 9001
def set_env_port(env_path, port=DEPLOY_PORT):
@@ -163,22 +171,32 @@ def set_env_mini_program_state(env_path, state):
f.writelines(new_lines)
+def resolve_binary_pack_env_src(root):
+ """binary 模式 tar 包内 .env 的来源,与 Docker 自动优先级一致。"""
+ for name in (".env.development", ".env.production", ".env"):
+ p = os.path.join(root, name)
+ if os.path.isfile(p):
+ return p, name
+ return None, None
+
+
def pack_deploy(root, binary_path, include_env=True):
"""打包二进制和 .env 为 tar.gz"""
print("[2/4] 打包部署文件 ...")
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
- env_src = os.path.join(root, ".env.development")
staging_env = os.path.join(staging, ".env")
- if include_env and os.path.isfile(env_src):
- shutil.copy2(env_src, staging_env)
- print(" [已包含] .env.development -> .env")
- else:
- env_example = os.path.join(root, ".env.example")
- if os.path.isfile(env_example):
- shutil.copy2(env_example, staging_env)
- print(" [已包含] .env.example -> .env (请服务器上检查配置)")
+ if include_env:
+ env_src, env_label = resolve_binary_pack_env_src(root)
+ if env_src:
+ shutil.copy2(env_src, staging_env)
+ print(" [已包含] %s -> .env" % env_label)
+ else:
+ env_example = os.path.join(root, ".env.example")
+ if os.path.isfile(env_example):
+ shutil.copy2(env_example, staging_env)
+ print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if os.path.isfile(staging_env):
set_env_port(staging_env, DEPLOY_PORT)
set_env_mini_program_state(staging_env, "developer")
@@ -392,6 +410,37 @@ def deploy_nginx_via_bt_api(cfg, nginx_conf_path, new_port):
# ==================== Docker 部署(蓝绿无缝切换) ====================
+def resolve_docker_env_file(root, explicit=None):
+ """
+ 选择打入镜像的环境文件(相对 soul-api 根目录,须能被 Docker 构建上下文包含)。
+ Dockerfile: COPY ${ENV_FILE} /app/.env;certs/ 由 COPY certs/ 一并打入。
+ 优先级:explicit → DOCKER_ENV_FILE → 自动 .env.development > .env.production > .env(与测试环境默认一致)
+ """
+ if explicit:
+ name = os.path.basename(explicit.replace("\\", "/"))
+ path = os.path.join(root, name)
+ if os.path.isfile(path):
+ print(" [镜像配置] 打入镜像的环境文件: %s(--env-file)" % name)
+ return name
+ print(" [失败] --env-file 不存在: %s" % path)
+ return None
+ override = (os.environ.get("DOCKER_ENV_FILE") or "").strip()
+ if override:
+ name = os.path.basename(override.replace("\\", "/"))
+ path = os.path.join(root, name)
+ if os.path.isfile(path):
+ print(" [镜像配置] 打入镜像的环境文件: %s(DOCKER_ENV_FILE)" % name)
+ return name
+ print(" [失败] DOCKER_ENV_FILE 指向的文件不存在: %s" % path)
+ return None
+ for name in (".env.development", ".env.production", ".env"):
+ if os.path.isfile(os.path.join(root, name)):
+ print(" [镜像配置] 打入镜像的环境文件: %s(自动选择)" % name)
+ return name
+ print(" [失败] 未找到 .env.development / .env.production / .env,无法 COPY 进镜像")
+ return None
+
+
def run_docker_build(root, env_file=".env.development"):
"""本地构建 Docker 镜像(使用 Docker 内的 golang 镜像)"""
print("[1/5] 构建 Docker 镜像 ...(进度见下方 Docker 输出)")
@@ -415,14 +464,14 @@ def run_docker_build(root, env_file=".env.development"):
def run_docker_build_local(root, env_file=".env.development"):
- """使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像)"""
+ """使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像,--pull=false 不拉 base 镜像)"""
print("[1/5] 使用本地 Go 交叉编译 ...")
binary_path = run_build(root)
if not binary_path:
return None
- print("[2/5] 使用 Dockerfile.local 构建镜像 ...(进度见下方 Docker 输出)")
+ print("[2/5] 使用 Dockerfile.local 构建镜像 ...(--pull=false 仅用本地缓存)")
try:
- cmd = ["docker", "build", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest",
+ cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest",
"--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."]
r = subprocess.run(cmd, cwd=root, shell=False, timeout=120)
if r.returncode != 0:
@@ -444,7 +493,7 @@ def run_docker_build_local(root, env_file=".env.development"):
def pack_docker_image(root):
"""将 soul-api 与 redis 镜像一并导出为 tar.gz,服务器无需拉取"""
import gzip
- print("[2/5] 导出镜像为 tar.gz(soul-api + redis)...")
+ print("[3/5] 导出镜像为 tar.gz(soul-api + redis)...")
out_tar = os.path.join(tempfile.gettempdir(), "soul_api_image.tar.gz")
try:
r = subprocess.run(
@@ -479,7 +528,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
nginx_conf = os.environ.get("DEPLOY_NGINX_CONF", DEPLOY_NGINX_CONF)
script_dir = os.path.dirname(os.path.abspath(__file__))
- print("[3/5] SSH 上传镜像与配置 ...")
+ print("[4/5] SSH 上传镜像与配置 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
@@ -506,9 +555,11 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
if os.path.isfile(deploy_local):
sftp.put(deploy_local, deploy_path + "/docker-deploy-remote.sh")
print(" [已上传] docker-deploy-remote.sh")
+ # 注意:docker-compose.bluegreen.yml 未配置 env_file,容器实际以镜像内 /app/.env 为准;
+ # 此处上传仅供服务器目录备份或手工改 compose 后使用。
if include_env and os.path.isfile(env_local):
sftp.put(env_local, deploy_path.rstrip("/") + "/.env")
- print(" [已上传] .env.production -> .env(覆盖镜像内配置)")
+ print(" [已上传] .env.production -> 服务器 %s/.env(可选;默认不挂载进容器)" % deploy_path.rstrip("/"))
# btapi 模式:需先读取 .active 计算新端口,脚本内跳过 Nginx
current_active = "blue"
@@ -523,7 +574,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
sftp.close()
- print("[4/5] 执行蓝绿部署 ...")
+ print("[5/5] 执行蓝绿部署 ...")
env_exports = ""
if nginx_conf:
env_exports += "export DEPLOY_NGINX_CONF='%s'; " % nginx_conf.replace("'", "'\\''")
@@ -546,12 +597,12 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
# btapi 模式:通过宝塔 API 更新 Nginx 配置并重载(new_port 已在上方计算)
if deploy_method == "btapi" and nginx_conf:
try:
- print("[5/5] 宝塔 API 更新 Nginx ...")
+ print(" [宝塔 API] 更新 Nginx ...")
deploy_nginx_via_bt_api(cfg, nginx_conf, new_port)
except Exception as e:
print(" [警告] 宝塔 Nginx API 失败:", str(e))
- print("[5/5] 部署完成,蓝绿无缝切换")
+ print(" 部署完成,蓝绿无缝切换")
return True
except Exception as e:
print(" [失败] SSH 错误:", str(e))
@@ -568,14 +619,17 @@ def main():
parser.add_argument("--mode", choices=("binary", "docker"), default="docker",
help="docker=Docker 蓝绿部署 (默认), binary=Go 二进制")
parser.add_argument("--no-build", action="store_true", help="跳过本地编译/构建")
- parser.add_argument("--no-env", action="store_true", help="不打包 .env")
+ parser.add_argument("--no-env", action="store_true",
+ help="binary: 不打进 tar;docker: 不上传服务器目录 .env.production(镜像内配置不变)")
parser.add_argument("--no-restart", action="store_true", help="[binary] 上传后不重启")
parser.add_argument("--restart-method", choices=("auto", "btapi", "ssh"), default="auto",
help="[binary] 重启方式: auto/btapi/ssh")
- parser.add_argument("--local-go", action="store_true",
- help="[docker] 使用本地 Go 交叉编译后打镜像,不拉取 golang 镜像")
+ parser.add_argument("--docker-in-go", action="store_true",
+ help="[docker] 在 Docker 内用 golang 镜像编译(默认:本地 go build → 再打镜像)")
parser.add_argument("--deploy-method", choices=("ssh", "btapi"), default="ssh",
help="[docker] 部署方式: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx 配置并重载 (默认 ssh)")
+ parser.add_argument("--env-file", default=None, metavar="NAME",
+ help="[docker] 打入镜像的环境文件名(默认自动:.env.development > .env.production > .env)")
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -592,11 +646,19 @@ def main():
print("=" * 60)
if not args.no_build:
- ok = run_docker_build_local(root) if args.local_go else run_docker_build(root)
+ env_for_image = resolve_docker_env_file(root, explicit=args.env_file)
+ if env_for_image is None:
+ return 1
+ # 默认:本地 go build → Dockerfile.local 打镜像;--docker-in-go 时在容器内编译
+ ok = (
+ run_docker_build(root, env_file=env_for_image)
+ if args.docker_in_go
+ else run_docker_build_local(root, env_file=env_for_image)
+ )
if not ok:
return 1
else:
- print("[1/5] 跳过构建,使用现有 soul-api:latest")
+ print("[1/5] 跳过构建,使用现有 soul-api:latest(无需本地环境文件)")
image_tar = pack_docker_image(root)
if not image_tar:
diff --git a/soul-api/internal/cache/cache.go b/soul-api/internal/cache/cache.go
index e2dd4102..8c4dff69 100644
--- a/soul-api/internal/cache/cache.go
+++ b/soul-api/internal/cache/cache.go
@@ -48,10 +48,15 @@ const KeyConfigAuditMode = "soul:config:audit-mode"
const KeyConfigCore = "soul:config:core"
const KeyConfigReadExtras = "soul:config:read-extras"
-// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB)
+// Get 从 Redis 读取,未配置或失败时尝试内存备用;均失败返回 false(调用方回退 DB)
func Get(ctx context.Context, key string, dest interface{}) bool {
client := redis.Client()
if client == nil {
+ // Redis 不可用,使用内存备用
+ if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
+ _ = json.Unmarshal(data, dest)
+ return true
+ }
return false
}
if ctx == nil {
@@ -61,6 +66,11 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
defer cancel()
val, err := client.Get(ctx, key).Bytes()
if err != nil {
+ // Redis 超时/失败时尝试内存备用
+ if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
+ _ = json.Unmarshal(data, dest)
+ return true
+ }
return false
}
if dest != nil && len(val) > 0 {
@@ -69,10 +79,16 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
return true
}
-// Set 写入 Redis,失败仅打日志不阻塞
+// Set 写入 Redis,Redis 不可用时写入内存备用;失败仅打日志不阻塞
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
+ data, err := json.Marshal(val)
+ if err != nil {
+ log.Printf("cache.Set marshal %s: %v", key, err)
+ return
+ }
client := redis.Client()
if client == nil {
+ memorySet(key, data, ttl)
return
}
if ctx == nil {
@@ -80,22 +96,20 @@ func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
- data, err := json.Marshal(val)
- if err != nil {
- log.Printf("cache.Set marshal %s: %v", key, err)
- return
- }
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
- log.Printf("cache.Set %s: %v (非致命)", key, err)
+ log.Printf("cache.Set %s: %v (非致命),已写入内存备用", key, err)
+ memorySet(key, data, ttl)
}
}
-// Del 删除 key,失败仅打日志
+// Del 删除 key,Redis 不可用时删除内存备用
func Del(ctx context.Context, key string) {
client := redis.Client()
if client == nil {
+ memoryDel(key)
return
}
+ memoryDel(key)
if ctx == nil {
ctx = context.Background()
}
@@ -106,12 +120,14 @@ func Del(ctx context.Context, key string) {
}
}
-// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),用于批量失效
+// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),Redis 不可用时删除内存备用
func DelPattern(ctx context.Context, pattern string) {
client := redis.Client()
if client == nil {
+ memoryDelPattern(pattern)
return
}
+ memoryDelPattern(pattern)
if ctx == nil {
ctx = context.Background()
}
@@ -183,10 +199,13 @@ func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sp
// ChapterContentTTL 章节正文 TTL,后台更新时主动 Del
const ChapterContentTTL = 30 * time.Minute
-// GetString 读取字符串(不经过 JSON,适合大文本 content)
+// GetString 读取字符串(不经过 JSON,适合大文本 content),Redis 不可用时尝试内存备用
func GetString(ctx context.Context, key string) (string, bool) {
client := redis.Client()
if client == nil {
+ if data, ok := memoryGet(key); ok {
+ return string(data), true
+ }
return "", false
}
if ctx == nil {
@@ -196,15 +215,19 @@ func GetString(ctx context.Context, key string) (string, bool) {
defer cancel()
val, err := client.Get(ctx, key).Result()
if err != nil {
+ if data, ok := memoryGet(key); ok {
+ return string(data), true
+ }
return "", false
}
return val, true
}
-// SetString 写入字符串(不经过 JSON,适合大文本 content)
+// SetString 写入字符串(不经过 JSON,适合大文本 content),Redis 不可用时写入内存备用
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
client := redis.Client()
if client == nil {
+ memorySet(key, []byte(val), ttl)
return
}
if ctx == nil {
@@ -213,7 +236,8 @@ func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
- log.Printf("cache.SetString %s: %v (非致命)", key, err)
+ log.Printf("cache.SetString %s: %v (非致命),已写入内存备用", key, err)
+ memorySet(key, []byte(val), ttl)
}
}
diff --git a/soul-api/internal/cache/memory.go b/soul-api/internal/cache/memory.go
new file mode 100644
index 00000000..1ba92ca5
--- /dev/null
+++ b/soul-api/internal/cache/memory.go
@@ -0,0 +1,52 @@
+package cache
+
+import (
+ "strings"
+ "sync"
+ "time"
+)
+
+// memoryFallback Redis 不可用时的内存备用缓存,保证服务可用
+var (
+ memoryMu sync.RWMutex
+ memoryData = make(map[string]*memoryEntry)
+)
+
+type memoryEntry struct {
+ Data []byte
+ Expiry time.Time
+}
+
+func memoryGet(key string) ([]byte, bool) {
+ memoryMu.RLock()
+ defer memoryMu.RUnlock()
+ e, ok := memoryData[key]
+ if !ok || e == nil || time.Now().After(e.Expiry) {
+ return nil, false
+ }
+ return e.Data, true
+}
+
+func memorySet(key string, data []byte, ttl time.Duration) {
+ memoryMu.Lock()
+ defer memoryMu.Unlock()
+ memoryData[key] = &memoryEntry{Data: data, Expiry: time.Now().Add(ttl)}
+}
+
+func memoryDel(key string) {
+ memoryMu.Lock()
+ defer memoryMu.Unlock()
+ delete(memoryData, key)
+}
+
+// memoryDelPattern 按前缀删除(pattern 如 soul:book:chapters-by-part:* 转为前缀 soul:book:chapters-by-part:)
+func memoryDelPattern(pattern string) {
+ prefix := strings.TrimSuffix(pattern, "*")
+ memoryMu.Lock()
+ defer memoryMu.Unlock()
+ for k := range memoryData {
+ if strings.HasPrefix(k, prefix) {
+ delete(memoryData, k)
+ }
+ }
+}
diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go
index 059d4e02..9522f79c 100644
--- a/soul-api/internal/handler/db.go
+++ b/soul-api/internal/handler/db.go
@@ -13,7 +13,6 @@ import (
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
- "soul-api/internal/redis"
"github.com/gin-gonic/gin"
)
@@ -92,6 +91,20 @@ func buildMiniprogramConfig() gin.H {
}
}
}
+ // 价格:以管理端「站点与作者」site_settings 为准(运营唯一配置入口),无则用 chapter_config 或默认值
+ var siteRow model.SystemConfig
+ if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 {
+ var siteVal map[string]interface{}
+ if err := json.Unmarshal(siteRow.ConfigValue, &siteVal); err == nil {
+ cur := out["prices"].(gin.H)
+ if v, ok := siteVal["sectionPrice"].(float64); ok && v > 0 {
+ cur["section"] = v
+ }
+ if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 {
+ cur["fullbook"] = v
+ }
+ }
+ }
// 好友优惠(用于 read 页展示优惠价)
var refRow model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
@@ -159,14 +172,8 @@ func GetPublicDBConfig(c *gin.Context) {
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
// 缓存未命中时仅查 mp_config 一条记录,避免 buildMiniprogramConfig 全量查询导致超时
-// 无 Redis 时直接返回数据库中的 auditMode 值,保证可用
+// Redis 不可用时 cache 包自动降级到内存备用
func GetAuditMode(c *gin.Context) {
- // 无 Redis 时跳过缓存,直接返回数据库值
- if redis.Client() == nil {
- auditMode := getAuditModeFromDB()
- c.JSON(http.StatusOK, gin.H{"auditMode": auditMode})
- return
- }
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
diff --git a/soul-api/internal/handler/db_book.go b/soul-api/internal/handler/db_book.go
index 8dd34aa3..2d55943b 100644
--- a/soul-api/internal/handler/db_book.go
+++ b/soul-api/internal/handler/db_book.go
@@ -610,6 +610,38 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
+ // update-chapter-pricing:按篇+章批量更新该章下所有「节」行的 price / is_free(管理端章节统一定价)
+ if body.Action == "update-chapter-pricing" {
+ if body.PartID == "" || body.ChapterID == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 partId 或 chapterId"})
+ return
+ }
+ p := 1.0
+ if body.Price != nil {
+ p = *body.Price
+ }
+ free := false
+ if body.IsFree != nil {
+ free = *body.IsFree
+ }
+ if free {
+ p = 0
+ }
+ up := map[string]interface{}{
+ "price": p,
+ "is_free": free,
+ }
+ res := db.Model(&model.Chapter{}).Where("part_id = ? AND chapter_id = ?", body.PartID, body.ChapterID).Updates(up)
+ if res.Error != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": res.Error.Error()})
+ return
+ }
+ cache.InvalidateBookParts()
+ InvalidateChaptersByPartCache()
+ cache.InvalidateBookCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "已更新本章全部节的定价", "affected": res.RowsAffected})
+ return
+ }
if body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
diff --git a/soul-api/internal/redis/redis.go b/soul-api/internal/redis/redis.go
index ed3e5412..14cf574e 100644
--- a/soul-api/internal/redis/redis.go
+++ b/soul-api/internal/redis/redis.go
@@ -21,6 +21,8 @@ func Init(url string) error {
client = redis.NewClient(opt)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
+ client = nil // 连接失败时清空,避免后续使用超时;cache 将自动降级到内存备用
+ log.Printf("redis: 连接失败,已降级到内存缓存(%v)", err)
return err
}
log.Printf("redis: connected to %s", opt.Addr)