From d863a42d8aec1f9f28d1a508566f762eb1e1644a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Mon, 31 Aug 2026 16:37:26 +0800 Subject: [PATCH] 1 --- docs/superpowers/plans/2026-08-31-模型清单.md | 2 +- .../specs/2026-08-31-模型清单-design.md | 4 +- flutter_app/lib/models/model_manager.dart | 152 +++++++++++++++--- flutter_app/pubspec.yaml | 2 +- flutter_app/test/model_manager_test.dart | 126 +++++++++++---- server/README.md | 2 +- server/biz/service/admin_test.go | 36 ++++- server/biz/service/auth.go | 34 +++- server/biz/service/testdata/observer_test.db | Bin 106496 -> 155648 bytes 9 files changed, 291 insertions(+), 67 deletions(-) diff --git a/docs/superpowers/plans/2026-08-31-模型清单.md b/docs/superpowers/plans/2026-08-31-模型清单.md index c9d55dc..bf16315 100644 --- a/docs/superpowers/plans/2026-08-31-模型清单.md +++ b/docs/superpowers/plans/2026-08-31-模型清单.md @@ -160,7 +160,7 @@ Expected: 无输出、exit 0 curl -s -o /dev/null -w "%{http_code} %{content_type}\n" "http://127.0.0.1:8080/api/v1/app/cover?namePrefix=RNPHE" curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8080/api/v1/app/cover?namePrefix=不存在的" ``` -Expected: 第一行 `200 image/jpeg`(或图片 mime),第二行 `500`(统一包装的错误码;若无 404 专门码,确认返回非 200 即可)。 +Expected: 第一行 `200 image/jpeg`(或图片 mime),第二行 `200` + body `{"code":1010,...}`(项目统一响应惯例:HTTP 恒 200,业务错误在 body code;App 端图片解码失败走占位图)。 - [ ] **Step 5: Commit** diff --git a/docs/superpowers/specs/2026-08-31-模型清单-design.md b/docs/superpowers/specs/2026-08-31-模型清单-design.md index 7d076b6..5441240 100644 --- a/docs/superpowers/specs/2026-08-31-模型清单-design.md +++ b/docs/superpowers/specs/2026-08-31-模型清单-design.md @@ -27,7 +27,7 @@ - 按 namePrefix 查数据集(`dao.Dataset`),取其 `Cover` 字段文件名。 - 返回该封面文件(复用现有封面文件定位逻辑,参照 `service/dataset.go` 的 `CoverFile`)。 -- 数据集不存在 / 无封面 / 文件缺失 → 404。 +- 数据集不存在 / 无封面 / 文件缺失 → 统一错误码(HTTP 200 + body code,项目统一响应惯例;App 端图片解码失败走占位图)。 - 分层约束:controller(app_version.go 或新增)→ service(查数据集 + 定位文件)→ dao;响应体直接写(文件型响应属「直接写响应体」例外,由 controller 完成,与现有 admin 图片接口一致)。 ### 2. models[] 增加 coverUrl @@ -88,5 +88,5 @@ app 端拼接方式与 downloadUrl 相同:`$baseUrl + coverUrl`。 ## 影响面与风险 - 行为变更:启动不再自动下载模型 → 首次使用需手动点「使用」(已确认)。 -- 封面 URL 使用 namePrefix 定位数据集:无 namePrefix 的数据集不会出现在目录中(目录只含已训练模型,均有前缀);路由对缺失前缀返回 404,app 显示占位图。 +- 封面 URL 使用 namePrefix 定位数据集:无 namePrefix 的数据集不会出现在目录中(目录只含已训练模型,均有前缀);路由对缺失前缀返回统一错误码(HTTP 200 + body code),app 图片解码失败显示占位图。 - 不新增依赖:图片用内置 `Image.network`。 diff --git a/flutter_app/lib/models/model_manager.dart b/flutter_app/lib/models/model_manager.dart index 0783ac0..6373019 100644 --- a/flutter_app/lib/models/model_manager.dart +++ b/flutter_app/lib/models/model_manager.dart @@ -76,11 +76,34 @@ class ModelManager extends ChangeNotifier { final Future Function()? _rootDirOverride; List _models = const []; + List _catalog = const []; + final Set _activeIds = {}; + final Set _downloadedIds = {}; + final Map _progress = {}; + final Map _errors = {}; + bool _activeLoaded = false; bool _ready = false; bool _refreshing = false; String? _error; Future? _inFlight; + /// 服务器目录(弹层模型清单展示用) + List get catalog => _catalog; + + /// 激活模型 id 集合(多选叠加) + Set get activeDatasetIds => Set.unmodifiable(_activeIds); + + bool isActive(int datasetId) => _activeIds.contains(datasetId); + + /// 该数据集模型文件是否已下载到本地(同步判断,内存态) + bool isDownloaded(int datasetId) => _downloadedIds.contains(datasetId); + + /// 下载进度 0..1(无下载/已完成为 null) + double? progressOf(int datasetId) => _progress[datasetId]; + + /// 下载失败原因(失败后可重试) + String? errorOf(int datasetId) => _errors[datasetId]; + ModelManager._({String? baseUrl, http.Client? client}) : this(baseUrl: baseUrl, client: client); @@ -94,7 +117,7 @@ class ModelManager extends ChangeNotifier { _client = client ?? http.Client(), _rootDirOverride = rootDir; - /// 已就绪模型列表(空 = 未下载任何模型,相机页仅预览) + /// 已激活且已下载的模型列表(空 = 未下载任何模型,相机页仅预览) List get models => _models; /// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空") @@ -125,6 +148,7 @@ class ModelManager extends ChangeNotifier { Future _doRefresh() async { try { + await _loadActive(); final res = await _client .get(Uri.parse('$baseUrl/api/v1/app/update')) .timeout(const Duration(seconds: 8)); @@ -133,65 +157,108 @@ class ModelManager extends ChangeNotifier { jsonDecode(utf8.decode(res.bodyBytes)) as Map; final data = body['data'] as Map? ?? const {}; final list = data['models'] as List? ?? const []; - final catalog = list + _catalog = list .map((e) => ModelCatalogItem.fromJson(e as Map)) .toList(); - final failed = []; - for (final item in catalog) { - if (!await _ensureLocal(item)) failed.add(item.datasetName); + // 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示 + final downloaded = {}; + for (final item in _catalog) { + if (await _isLocal(item)) downloaded.add(item.datasetId); } - await _prune(catalog); - _models = await _loadBundles(catalog); + _downloadedIds + ..clear() + ..addAll(downloaded); + + await _prune(_catalog); + // 服务器已下线的数据集移出激活集 + final catalogIds = _catalog.map((c) => c.datasetId).toSet(); + if (_activeIds.any((id) => !catalogIds.contains(id))) { + _activeIds.removeWhere((id) => !catalogIds.contains(id)); + await _saveActive(); + } + + _models = await _loadBundles(_catalog); _ready = true; - _error = failed.isEmpty - ? null - : '模型下载失败:${failed.join(',')}(重试旧模型或稍后再试)'; + _error = null; } catch (e) { if (!_ready) _error = '模型目录拉取失败:$e'; - // 已就绪过则保留旧模型,不覆盖 error(下载级错误优先展示) + // 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示) } } - /// 保证目录条目在本地可用:meta 匹配且文件在 → 跳过;否则下载并校验 sha256。 - Future _ensureLocal(ModelCatalogItem item) async { + /// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在) + Future _isLocal(ModelCatalogItem item) async { final dir = await _modelDir(item.datasetId); try { final meta = await _readMeta(dir); final file = File('${dir.path}/model.tflite'); - if (meta != null && + return meta != null && meta['version'] == item.version && meta['sha256'] == item.sha256 && - await file.exists()) { - return true; - } - // 版本更新或文件缺失:下载校验(失败重试一次) + await file.exists(); + } catch (e) { + return false; + } + } + + /// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta); + /// 成功自动加入激活集(下载完成即使用)。失败重试一次并记录错误。 + Future downloadModel(ModelCatalogItem item, + {void Function(int received, int total)? onProgress}) async { + final dir = await _modelDir(item.datasetId); + final file = File('${dir.path}/model.tflite'); + try { for (var attempt = 0; attempt < 2; attempt++) { - final ok = await _downloadAndVerify(item, dir, file); - if (ok) return true; + final ok = await _downloadAndVerify(item, dir, file, + onProgress: (r, t) { + _progress[item.datasetId] = t == 0 ? 0 : r / t; + onProgress?.call(r, t); + notifyListeners(); + }); + if (ok) { + _progress.remove(item.datasetId); + _errors.remove(item.datasetId); + _downloadedIds.add(item.datasetId); + await setActive(item.datasetId, true); + return true; + } await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); } + _progress.remove(item.datasetId); + _errors[item.datasetId] = '下载失败,请重试'; + notifyListeners(); debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}'); return false; } catch (e) { - debugPrint('[ModelManager] _ensureLocal ${item.datasetName}: $e'); + _progress.remove(item.datasetId); + _errors[item.datasetId] = '下载异常:$e'; + notifyListeners(); + debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e'); return false; } } Future _downloadAndVerify( - ModelCatalogItem item, Directory dir, File file) async { + ModelCatalogItem item, Directory dir, File file, + {void Function(int received, int total)? onProgress}) async { final part = File('${file.path}.part'); final sink = part.openWrite(); + var received = 0; try { final res = await _client .send(http.Request('GET', Uri.parse('$baseUrl${item.downloadUrl}'))) .timeout(const Duration(minutes: 3)); if (res.statusCode != 200) return false; - await res.stream.pipe(sink); + final total = res.contentLength ?? item.sizeBytes; + await for (final chunk in res.stream) { + sink.add(chunk); + received += chunk.length; + onProgress?.call(received, total); + } await sink.close(); final bytes = await part.readAsBytes(); final hex = sha256.convert(bytes).toString(); @@ -234,6 +301,45 @@ class ModelManager extends ChangeNotifier { } } + /// 设置激活状态(true=使用,false=取消);持久化到 /active.json。 + /// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。 + Future setActive(int datasetId, bool active) async { + final changed = + active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId); + if (!changed) return; + _models = await _loadBundles(_catalog); + await _saveActive(); + notifyListeners(); + } + + Future _saveActive() async { + try { + final root = await _rootDir(); + await root.create(recursive: true); + await File('${root.path}/active.json') + .writeAsString(jsonEncode({'active': _activeIds.toList()})); + } catch (e) { + debugPrint('[ModelManager] 激活集持久化失败: $e'); + } + } + + Future _loadActive() async { + if (_activeLoaded) return; + _activeLoaded = true; + try { + final root = await _rootDir(); + final f = File('${root.path}/active.json'); + if (!await f.exists()) return; + final data = jsonDecode(await f.readAsString()) as Map; + _activeIds + ..clear() + ..addAll((data['active'] as List? ?? const []) + .map((e) => (e as num).toInt())); + } catch (e) { + debugPrint('[ModelManager] 激活集读取失败: $e'); + } + } + Future> _loadBundles( List catalog) async { final bundles = []; diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 7ebfd61..66d5773 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -2,7 +2,7 @@ name: observer description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费" publish_to: 'none' -version: 1.0.5+6 +version: 1.0.6+7 environment: sdk: ^3.12.2 diff --git a/flutter_app/test/model_manager_test.dart b/flutter_app/test/model_manager_test.dart index 7c2e9c0..5520465 100644 --- a/flutter_app/test/model_manager_test.dart +++ b/flutter_app/test/model_manager_test.dart @@ -11,17 +11,27 @@ const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; String _shaHex(List bytes) => sha256.convert(bytes).toString(); -Map _catalog(List> models) => - {'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models}}; +Map _catalog(List> models) => { + 'code': 0, + 'message': 'ok', + 'data': {'version': '0.0.2', 'notes': '', 'models': models} + }; -Map _item({String version = 'v1.0.0', String sha = ''}) => { - 'datasetId': 7, - 'datasetName': '环颈雉鸡数据集', +Map _item({ + int datasetId = 7, + String name = '环颈雉鸡数据集', + String version = 'v1.0.0', + String sha = '', +}) => + { + 'datasetId': datasetId, + 'datasetName': name, 'version': version, 'labels': ['pheasant', 'suspect'], 'sizeBytes': _modelBytes.length, 'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha, - 'downloadUrl': '/download/models/环颈雉鸡数据集/latest.tflite', + 'downloadUrl': '/download/models/$datasetId/latest.tflite', + 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', }; void main() { @@ -41,10 +51,12 @@ void main() { rootDir: () async => root, ); - MockClient client(List> models) => MockClient((req) async { + MockClient client(List> models) => + MockClient((req) async { if (req.url.path == '/api/v1/app/update') { // Response(String) 默认 latin1 编码,中文数据集名会抛异常 → 用 bytes - return http.Response.bytes(utf8.encode(jsonEncode(_catalog(models))), 200); + return http.Response.bytes( + utf8.encode(jsonEncode(_catalog(models))), 200); } if (req.url.path.startsWith('/download/models/')) { downloadHits++; @@ -53,16 +65,34 @@ void main() { return http.Response('not found', 404); }); - test('首次拉取:下载模型并落盘(model/labels/meta)', () async { + test('refresh 只拉目录:不下载任何模型,coverUrl 解析正确', () async { final m = manager(client([_item()])); await m.refresh(); expect(m.ready, isTrue); expect(m.error, isNull); + expect(downloadHits, 0, reason: 'refresh 不应触发下载'); + expect(m.models, isEmpty, reason: '未激活的模型不应出现在 models'); + expect(m.catalog.length, 1); + expect(m.catalog.first.coverUrl, '/api/v1/app/cover?namePrefix=RNPHE'); + }); + + test('downloadModel:下载+校验+落盘+自动激活+进度回调', () async { + final m = manager(client([_item()])); + await m.refresh(); + + final progresses = []; + final ok = await m.downloadModel(m.catalog.first, onProgress: (r, t) { + progresses.add(t == 0 ? 0 : r / t); + }); + + expect(ok, isTrue); + expect(downloadHits, 1); + expect(m.isDownloaded(7), isTrue); + expect(m.isActive(7), isTrue, reason: '下载完成应自动使用'); + expect(progresses.last, 1.0); expect(m.models.length, 1); expect(m.models.first.datasetName, '环颈雉鸡数据集'); - expect(m.models.first.bytes, _modelBytes); - expect(downloadHits, 1); final dir = Directory('${root.path}/7'); expect(await File('${dir.path}/model.tflite').exists(), isTrue); @@ -70,67 +100,93 @@ void main() { expect(await File('${dir.path}/meta.json').exists(), isTrue); }); - test('版本未变不重复下载(meta 命中直接跳过)', () async { + test('已下载未激活:setActive 直接使用,不触发下载', () async { final m = manager(client([_item()])); await m.refresh(); - expect(downloadHits, 1); + await m.downloadModel(m.catalog.first); + await m.setActive(7, false); + expect(m.isActive(7), isFalse); + expect(m.models, isEmpty); - await m.refresh(); - expect(downloadHits, 1, reason: 'meta 匹配应跳过下载'); + final before = downloadHits; + await m.setActive(7, true); + expect(m.isActive(7), isTrue); + expect(downloadHits, before, reason: '已下载直接使用不应重新下载'); expect(m.models.length, 1); }); - test('版本更新触发重新下载', () async { + test('版本更新:downloadModel 重新下载', () async { final m = manager(client([_item()])); await m.refresh(); + await m.downloadModel(m.catalog.first); expect(downloadHits, 1); - await m.refresh(); - expect(downloadHits, 1); - - // 发布新版本:再次刷新应重下 - await m.refresh(); - expect(downloadHits, 1); - // 上面三次同一版本,重新构造带新版本的 manager(同一 root) final m2 = manager(client([_item(version: 'v2.0.0')])); await m2.refresh(); + await m2.downloadModel(m2.catalog.first); expect(downloadHits, 2); expect(m2.models.first.version, 'v2.0.0'); }); - test('sha256 不匹配:重试后失败,保留旧模型并报错', () async { - // 第一次下载成功(sha 匹配) - final m1 = manager(client([_item()])); - await m1.refresh(); - expect(m1.models.length, 1); - - // 服务器 sha 与文件不符(被篡改/损坏)→ 下载校验失败 + test('sha256 不匹配:重试后失败、不激活、错误可见', () async { + final m = manager(client([_item()])); + await m.refresh(); final bad = _item(version: 'v3.0.0'); bad['sha256'] = _shaHex([9, 9, 9]); final m2 = manager(client([bad])); await m2.refresh(); - expect(m2.models.length, 0, reason: '校验失败的模型不应加载'); - expect(m2.error, isNotNull); - expect(m2.error, contains('环颈雉鸡数据集')); + final ok = await m2.downloadModel(m2.catalog.first); + expect(ok, isFalse); + expect(downloadHits, 2, reason: '校验失败应重试一次'); + expect(m2.isDownloaded(7), isFalse); + expect(m2.isActive(7), isFalse); + expect(m2.errorOf(7), isNotNull); }); - test('目录下线:清理本地并清空模型', () async { + test('激活集持久化:重启后恢复激活且已下载的模型', () async { final m = manager(client([_item()])); await m.refresh(); - expect(m.models.length, 1); + await m.downloadModel(m.catalog.first); + + // 同一 root 新建 manager 模拟重启 + final m2 = manager(client([_item()])); + await m2.refresh(); + expect(m2.isActive(7), isTrue, reason: '激活集应持久化'); + expect(m2.models.length, 1); + expect(downloadHits, 1, reason: '重启不应触发下载'); + }); + + test('目录下线:清理本地并移除激活', () async { + final m = manager(client([_item()])); + await m.refresh(); + await m.downloadModel(m.catalog.first); expect(await Directory('${root.path}/7').exists(), isTrue); final m2 = manager(client([])); await m2.refresh(); expect(m2.models, isEmpty); + expect(m2.isActive(7), isFalse, reason: '下线的模型应移出激活集'); expect(await Directory('${root.path}/7').exists(), isFalse, reason: '下线的数据集模型目录应被清理'); }); + test('多模型:只激活其一则只加载其一', () async { + final m = manager( + client([_item(datasetId: 7, name: '环颈雉'), _item(datasetId: 8, name: '斑鸠')])); + await m.refresh(); + await m.downloadModel(m.catalog.first); // 只下载并激活 7 + expect(m.models.length, 1); + expect(m.models.first.datasetId, 7); + + await m.downloadModel(m.catalog.last); // 下载 8 自动激活 + expect(m.models.length, 2); + }); + test('目录接口异常:不覆盖已有就绪状态', () async { final m = manager(client([_item()])); await m.refresh(); + await m.downloadModel(m.catalog.first); expect(m.ready, isTrue); final broken = manager(MockClient((_) async => http.Response('boom', 500))); diff --git a/server/README.md b/server/README.md index 1e09ec5..c5097b2 100644 --- a/server/README.md +++ b/server/README.md @@ -67,7 +67,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发 ### POST /api/v1/auth/login -手机号登录。请求:`{"phone": "13800000000", "password": "..."}`。响应 `data: {"token": "..."}`(HMAC 自签名,有效期 `auth.token_ttl`,默认 30 天)。后续登录态接口携带 `Authorization: Bearer `。 +手机号登录(自动注册式:手机号不存在则以输入密码创建账号,存在但密码为空则补写输入密码,仅已有密码时才做密码比对,比对失败报错;创建/补密码后即登录成功)。请求:`{"phone": "13800000000", "password": "..."}`。响应 `data: {"token": "..."}`(HMAC 自签名,有效期 `auth.token_ttl`,默认 30 天)。后续登录态接口携带 `Authorization: Bearer `。 ### GET /api/v1/plans diff --git a/server/biz/service/admin_test.go b/server/biz/service/admin_test.go index 2577df4..133bdf1 100644 --- a/server/biz/service/admin_test.go +++ b/server/biz/service/admin_test.go @@ -45,9 +45,39 @@ func TestAuthRegisterLogin(t *testing.T) { if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "other123"}); err == nil { t.Fatal("duplicate register should fail") } - // 未注册手机号登录失败 - if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: uniquePhone(), Password: "pass123456"}); err == nil { - t.Fatal("login unregistered phone should fail") + // 未注册手机号登录自动创建账号并登录成功(自动注册式登录) + phone2 := uniquePhone() + res2, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone2, Password: "pass123456"}) + if err != nil { + t.Fatalf("login auto-register: %v", err) + } + got2, err := common.ParseToken(ctx(), res2.Token) + if err != nil || got2 != phone2 { + t.Fatalf("auto-register token = %q, %v; want %q", got2, err, phone2) + } + // 自动注册后已存在,重复注册报错 + if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone2, Password: "other123"}); err == nil { + t.Fatal("register after auto-register should fail") + } +} + +// TestAuthLoginFillPlaceholder 运营发卡占位行(无密码)→ 登录用输入密码补写并成功,授权保留;错误密码被拒 +func TestAuthLoginFillPlaceholder(t *testing.T) { + phone := uniquePhone() + if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil { + t.Fatalf("grant: %v", err) + } + if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil { + t.Fatalf("login fill password: %v", err) + } + // 补密码不能清掉已授权 + lic, err := dao.License.GetByPhone(ctx(), phone) + if err != nil || lic == nil || lic.ExpiresAt == nil { + t.Fatalf("grant lost after login fill password: %+v, %v", lic, err) + } + // 密码已写入:错误密码被拒 + if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "wrong"}); err == nil { + t.Fatal("wrong password should fail after fill") } } diff --git a/server/biz/service/auth.go b/server/biz/service/auth.go index 50b091c..67cfa03 100644 --- a/server/biz/service/auth.go +++ b/server/biz/service/auth.go @@ -46,12 +46,44 @@ func (s *authService) Register(ctx context.Context, req *dto.RegisterReq) (*dto. return &dto.RegisterRes{}, nil } -// Login 登录:绕缓存查账号(密码变更即时生效)→ bcrypt 比对 → 签发 token +// Login 登录(自动注册式):绕缓存查账号(密码变更即时生效)。 +// 手机号不存在 → 以输入密码创建账号;存在但密码为空(运营发卡占位行)→ 补写输入密码; +// 仅已有密码才做 bcrypt 比对,失败报错。创建/补密码后即视为登录成功签发 token。 func (s *authService) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) { lic, err := dao.License.GetByPhoneNoCache(ctx, req.Phone) if err != nil { return nil, err } + if lic == nil || lic.Password == "" { + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, gerror.Wrap(err, "密码加密失败") + } + err = common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + cur, err := dao.License.GetByPhoneInTx(ctx, tx, req.Phone) + if err != nil { + return err + } + if cur == nil { + return dao.License.Register(ctx, tx, req.Phone, string(hash)) + } + if cur.Password == "" { + return dao.License.SetPasswordInTx(ctx, tx, req.Phone, string(hash)) + } + return nil + }) + }) + if err != nil { + return nil, err + } + common.ClearCache(ctx, "license:"+req.Phone) + // 并发下密码可能已被他人补写,重新读取后统一走比对 + lic, err = dao.License.GetByPhoneNoCache(ctx, req.Phone) + if err != nil { + return nil, err + } + } if lic == nil || lic.Password == "" { return nil, gerror.New("账号或密码错误") } diff --git a/server/biz/service/testdata/observer_test.db b/server/biz/service/testdata/observer_test.db index af541f48d460324cefb0e0afa7b9c23359d7f5db..81833a75ebd8c27368b7ef678e1acea959173c0b 100644 GIT binary patch delta 10021 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 HJU{&}|3(dR delta 830 zcmYk2U1(cn9LC@O$@zTG`;m0DOCr;=b+x6KkYrnGBHFY|FQ#lw=Qck`Ya^y8?X(}+ zKoCji7|NJiILw!UmKB6fHcGNrJOdHC(F=8;Rxc)nTDB=zd$oa6m`u+uggx-TcpjdM z|MROZSk(pnRI~}MA%vp-{V!5`JoT8L#z+33)*>S28nRE2w7JH=p%s3NKg)v@@I1Lf z#$|1wY9WhZ>tuGsEoyESlVa!gFOD-kzuKjFya7q5+>@yqnM?oe#f6{^~U%x+QBfrb<@LPOcHlMx5<$8A{7?Aj? zGzwjy@|(27Ee>=l^rWnn1}(Qb+A05hiE9CASEVuB?^jl+D-V}Gv1;`rvO0QHjZ5`Q zgpZih<^-O^ub3rs!0a)Tc(3&-?zPTX@8cBh#apbmtk@mCt}US?`RcR*U4$2SH;>U7dV+m+dJi}XEy_^!Ino*mv_mGoeOqcrI*~}k z6UnSUDfD2H@oo^