1
This commit is contained in:
@@ -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**
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -76,11 +76,34 @@ class ModelManager extends ChangeNotifier {
|
||||
final Future<Directory> Function()? _rootDirOverride;
|
||||
|
||||
List<ModelBundle> _models = const [];
|
||||
List<ModelCatalogItem> _catalog = const [];
|
||||
final Set<int> _activeIds = {};
|
||||
final Set<int> _downloadedIds = {};
|
||||
final Map<int, double> _progress = {};
|
||||
final Map<int, String> _errors = {};
|
||||
bool _activeLoaded = false;
|
||||
bool _ready = false;
|
||||
bool _refreshing = false;
|
||||
String? _error;
|
||||
Future<void>? _inFlight;
|
||||
|
||||
/// 服务器目录(弹层模型清单展示用)
|
||||
List<ModelCatalogItem> get catalog => _catalog;
|
||||
|
||||
/// 激活模型 id 集合(多选叠加)
|
||||
Set<int> 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<ModelBundle> get models => _models;
|
||||
|
||||
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空")
|
||||
@@ -125,6 +148,7 @@ class ModelManager extends ChangeNotifier {
|
||||
|
||||
Future<void> _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<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
final list = data['models'] as List? ?? const [];
|
||||
final catalog = list
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final failed = <String>[];
|
||||
for (final item in catalog) {
|
||||
if (!await _ensureLocal(item)) failed.add(item.datasetName);
|
||||
// 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示
|
||||
final downloaded = <int>{};
|
||||
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<bool> _ensureLocal(ModelCatalogItem item) async {
|
||||
/// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在)
|
||||
Future<bool> _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<bool> 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<bool> _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=取消);持久化到 <root>/active.json。
|
||||
/// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。
|
||||
Future<void> 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<void> _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<void> _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<String, dynamic>;
|
||||
_activeIds
|
||||
..clear()
|
||||
..addAll((data['active'] as List? ?? const [])
|
||||
.map((e) => (e as num).toInt()));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 激活集读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ModelBundle>> _loadBundles(
|
||||
List<ModelCatalogItem> catalog) async {
|
||||
final bundles = <ModelBundle>[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,17 +11,27 @@ const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) =>
|
||||
{'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models}};
|
||||
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'version': '0.0.2', 'notes': '', 'models': models}
|
||||
};
|
||||
|
||||
Map<String, dynamic> _item({String version = 'v1.0.0', String sha = ''}) => {
|
||||
'datasetId': 7,
|
||||
'datasetName': '环颈雉鸡数据集',
|
||||
Map<String, dynamic> _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<Map<String, dynamic>> models) => MockClient((req) async {
|
||||
MockClient client(List<Map<String, dynamic>> 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 = <double>[];
|
||||
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)));
|
||||
|
||||
+1
-1
@@ -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 <token>`。
|
||||
手机号登录(自动注册式:手机号不存在则以输入密码创建账号,存在但密码为空则补写输入密码,仅已有密码时才做密码比对,比对失败报错;创建/补密码后即登录成功)。请求:`{"phone": "13800000000", "password": "..."}`。响应 `data: {"token": "..."}`(HMAC 自签名,有效期 `auth.token_ttl`,默认 30 天)。后续登录态接口携带 `Authorization: Bearer <token>`。
|
||||
|
||||
### GET /api/v1/plans
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("账号或密码错误")
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user