Dev-note·Aug 1, 2026·10 min read

Ngồi review lại đống code AI viết, khi sản phẩm bắt đầu có user thật

Tính năng chống gian lận mình để AI code rồi bỏ ngỏ. Giờ có user rồi mới ngồi soi lại, và hoá ra cái gate đó chưa chặn được ai bao giờ.

Q
QuyenTMThợ code

Mở bài

MoiCash là sản phẩm hoàn tiền mình đang làm, mua hàng qua link của app thì được hoàn lại một phần hoa hồng. Có tính năng giới thiệu bạn bè, mời được một người thì cả hai cùng có thưởng. Ai làm ví điện tử hay thương mại điện tử chắc cũng đoán được chuyện gì sẽ tới, đó là sẽ có người ngồi tạo hàng chục tài khoản để tự giới thiệu chính mình rồi ôm hết phần thưởng.

Giai đoạn đầu thì mình chạy nhanh là chính. Nhiều phần trong đó mình để AI code, mô tả yêu cầu rồi để nó dựng, mình đọc lướt thấy hợp lý là cho qua. Phần chống gian lận cũng vậy, và thú thật là mình bỏ ngỏ luôn, vì lúc đó có mấy ai dùng đâu mà lo bị farm.

Rồi user bắt đầu về thật. Không nhiều, nhưng đủ để mình nhận ra là tiền bắt đầu chảy ra khỏi hệ thống, và tới lúc phải ngồi lại soi cho tử tế.

Cái đầu tiên mình làm không phải là đọc code, mà là mở database lên đếm. Ba mươi cái referral, và số dòng có device fingerprint là con số không tròn trĩnh.

Không một dòng nào.

Cái gate chống farm đó, từ ngày viết ra chưa chặn được ai. Hàm kiểm tra có đủ, config ngưỡng có đủ, unit test cũng xanh lè. Chỉ thiếu mỗi việc là app chưa bao giờ gửi fingerprint lên server. Tham số nằm sẵn trong hàm gọi API, nhưng chẳng có chỗ nào truyền giá trị vào cả.

Bài này là mấy thứ mình nhặt được sau khi ngồi vá lại toàn bộ chỗ đó, và cũng là vài suy nghĩ về chuyện để AI code rồi review sau.

Thân bài

1. AI viết code trông rất đúng, nhưng không tự hỏi ai gọi nó

Đây là điểm mình thấy rõ nhất khi ngồi soi lại.

Cái gate chống farm đó nhìn vào thì chuẩn chỉnh, hàm tách riêng, tên biến rõ ràng, có comment giải thích, có xử lý lỗi đàng hoàng, có cả test. Nếu chấm điểm từng file một thì nó qua hết.

Vấn đề là chẳng có ai nối dây từ app lên tới đó. AI được giao việc viết logic kiểm tra, nó viết logic kiểm tra. Nó không tự đi hỏi ngược lại rằng cái tham số này ngoài đời có ai truyền vào không, ai gọi API này, app đã gửi chưa. Việc đó nằm ngoài cái ô mà mình khoanh cho nó.

AI làm rất tốt phần việc bạn giao. Cái nó không làm là hỏi xem bạn có giao thiếu không.

Mà đây lại đúng là kiểu lỗi khó thấy nhất khi review, vì mở file nào ra cũng thấy ổn cả. Nó chỉ lộ ra khi mình nhìn dữ liệu thật.

2. Test do AI viết chứng minh hàm chạy, không chứng minh luồng chạy

Chỗ này mình thấy khá thấm.

Test của cái gate đó viết rất bài bản. Cho mock trả về số lượng đã chạm ngưỡng, assert là bị chặn, xanh. Cho dưới ngưỡng, assert là cho qua, xanh. Nhìn coverage thì đẹp.

Nhưng cả bộ test đó được viết dựa trên đúng cái giả định của người viết code, mà ở đây người viết code là AI. Nó giả định có ai đó đưa số liệu vào, rồi test cái giả định của chính nó. Không ai hỏi câu quan trọng nhất là ngoài đời con số đó tới từ đâu.

Từ vụ này mình rút ra một thói quen rất rẻ mà hiệu quả. Với mọi cơ chế phòng thủ trong hệ thống, chạy luôn một câu query đếm xem nó đã kích hoạt lần nào chưa. Một dòng SQL nhiều khi đáng giá hơn cả một trang test.

3. Định danh mà không bền thì coi như không có định danh

Chỗ hổng gốc rễ nằm ở cách app định danh thiết bị. Nó sinh một cái UUID ngẫu nhiên rồi lưu vào SharedPreferences, lần sau mở app thì đọc lại.

Cách này không sai, và mình hiểu vì sao nó được chọn, vì để phục vụ push notification thì như vậy là đủ. Nhưng khi đem đúng cái định danh đó đi chống gian lận thì hỏng, bởi gỡ app là mất sạch. Người muốn farm chỉ cần xoá app cài lại là có ngay một thiết bị mới tinh trong mắt hệ thống.

Mọi thống kê dựa trên nó cũng méo theo. Trong database của mình có một tài khoản mang hai mươi hai thiết bị, một tài khoản khác mười hai. Không phải họ có hai mươi hai cái điện thoại, mà là hai mươi hai lần cài lại app.

Định danh bền hơn thì có, mà cũng chẳng phải bí mật gì. Trên iOS thì lấy identifierForVendor rồi tự lưu vào Keychain, vì Keychain sống sót qua việc gỡ app còn IDFV thì không. Trên Android thì dùng Settings.Secure.ANDROID_ID, từ Android 8 nó được scope theo chữ ký app cộng user cộng máy, và quan trọng là không đổi khi gỡ ra cài lại, miễn là package name với signing key giữ nguyên.

Có một cái bẫy nhỏ mà mình suýt dính. Package device_info_plus mà gần như dự án Flutter nào cũng có sẵn thì lại không cho ra SSAID. Trường id của nó là Build.ID, tức là phiên bản bản dựng hệ điều hành, giống nhau ở hàng triệu máy cùng đời. Lấy nhầm cái đó làm định danh thì còn tệ hơn cả UUID ban đầu.

4. Bền hơn không có nghĩa là đúng hơn

Lúc đi tìm giải pháp có gặp mấy package nhìn hấp dẫn hơn hẳn, chúng lấy định danh từ Widevine DRM, sống sót qua cả factory reset. Nghe tới đó là muốn cài vào dùng luôn.

May mà đọc thêm một vòng nữa. Hoá ra cái ID đó bị trùng giữa các máy cùng đời cùng hãng. Có người mua máy mới về không đăng ký được tài khoản, vì hệ thống báo cái ID đó đã có chủ rồi.

Đặt vào bài toán chống gian lận thì đây là chuyện kinh khủng, cả một dòng máy phổ thông bị gom thành một cụm, và hàng loạt người dùng thật bị chặn oan mà chẳng hiểu vì sao.

Một tín hiệu bền mà sai còn nguy hiểm hơn một tín hiệu yếu mà mình biết rõ là nó yếu.

Cuối cùng chọn cái kém bền hơn, tức là Keychain với SSAID, vì nó đúng. Mất dấu sau khi người ta factory reset thì chấp nhận được, chứ chặn oan người vừa mua máy mới thì không.

Đây cũng là loại quyết định mà mình nghĩ vẫn phải do con người chốt. AI đưa ra được cả hai lựa chọn kèm ưu nhược điểm rất nhanh, nhưng câu hỏi sai kiểu nào thì đỡ tệ hơn cho sản phẩm của mình thì nó không trả lời thay được.

5. Fingerprint là do client gửi lên, đừng bao giờ quên điều đó

Đây là cái giới hạn mà không dòng code nào ở server sửa được. Giá trị đó do app gửi lên, ai chịu khó sửa app hoặc gọi thẳng vào API thì vẫn bịa ra được một fingerprint mới cho mỗi lần đăng ký, và né sạch mọi ngưỡng mình đặt ra.

Nhận ra điều này quan trọng, vì nó quyết định mình nên dùng tín hiệu đó để làm gì. Nếu dùng nó làm bằng chứng để tự động khoá tài khoản thì sai, vì nó giả được. Còn dùng nó làm dấu hiệu gom cụm cho người thật ngồi xem lại thì đúng, vì mấy người farm nghiệp dư, tức là đa số, chẳng ai buồn ngồi mổ cái app làm gì.

Mục tiêu thực tế không phải là chặn tuyệt đối, mà là làm cho việc gian lận tốn công hơn phần thưởng thu được. Ai chịu bỏ cả buổi đi reverse app để lấy vài chục nghìn tiền thưởng thì thôi, cũng xin chúc mừng bạn.

6. Cái lỗi nguy hiểm nhất lại sinh ra ngay trong lúc đi vá

Đây là đoạn mình thấy đáng kể nhất, vì nó cho thấy vòng lặp AI code rồi người review nó vận hành như thế nào.

Trong lúc vá, để cái gate đếm cho chính xác, mình cho ghi fingerprint ngay lúc tạo tài khoản, nằm trong cùng một câu INSERT. Nghe hợp lý, và AI code phần đó cũng gọn gàng, test xanh hết.

Cái không ai để ý là fingerprint chưa hề được kiểm tra định dạng. Nó là chuỗi hash sáu mươi tư ký tự, cột trong database cũng để varchar sáu mươi tư. Ở chỗ khác trong code, mã giới thiệu có ràng buộc độ dài tối đa đàng hoàng. Riêng fingerprint thì trống trơn.

Mà MySQL trên production thì bật STRICT_TRANS_TABLES, nghĩa là chuỗi dài quá nó ném lỗi chứ không tự cắt.

Ghép ba thứ đó lại thì chỉ cần một request với fingerprint dài hai trăm ký tự là đủ chặn luôn đường đăng ký. Một thay đổi sinh ra để chống gian lận, chỉ vì thiếu một dòng validate, suýt trở thành cái công tắc tắt luôn tính năng đăng ký của cả sản phẩm.

Nó chỉ lộ ra ở vòng soi cuối cùng, khi mình bảo rà lại toàn bộ phần liên quan tới định danh người dùng xem còn thiếu gì. Tức là cái lỗi nặng nhất trong cả đợt không nằm trong đống code cũ bỏ ngỏ, mà sinh ra ngay trong lúc đang đi sửa cho tốt hơn.

Cách xử lý cũng đáng nói. Phản xạ đầu tiên là trả về 400 Bad Request cho gọn. Nhưng nghĩ kỹ lại thì sai, vì fingerprint chỉ là tín hiệu phụ trợ, dữ liệu xấu thì bỏ qua chứ đừng chặn cả cái request. Chặn tức là biến một công cụ chống gian lận thành cửa tử cho việc đăng nhập.

7. AI không nhìn thấy cái không tồn tại

Còn một chỗ nữa chỉ tìm ra khi rà lại toàn bộ luồng, chứ không phải lúc review cái diff.

Sản phẩm có hai đường xoá tài khoản, một là người dùng tự xoá trong app, hai là admin xoá từ dashboard. Đường thứ nhất có dọn bảng thiết bị, còn đường thứ hai thì quên mất. Hai đường này viết ở hai thời điểm khác nhau, và không ai ngồi so hai danh sách xem có khớp nhau không.

Bình thường thì đó chỉ là mấy dòng rác nằm im chẳng ai để ý. Nhưng khi bảng thiết bị được nâng lên thành nguồn dữ liệu cho gate chống farm, mà câu đếm lại không join sang bảng users, thì cái máy đó vẫn bị tính là đang cõng đủ số tài khoản, trong khi vài cái đã bị xoá từ lâu. Người dùng thật đến sau bị chặn mà chẳng hiểu chuyện gì.

Đây là điểm yếu mình thấy rõ khi làm việc với AI. Nó rất giỏi đọc code đang có và trả lời chỗ này làm gì, nhưng chuyện thiếu một bước dọn dẹp ở một file khác, viết cách đây mấy tháng, thì nó không tự nhìn ra được. Muốn nó thấy thì mình phải chủ động hỏi, kiểu như liệt kê hết những nơi đụng vào bảng này, hay so hai luồng xoá tài khoản xem có lệch nhau không.

Muốn AI tìm ra cái đang thiếu thì phải hỏi đúng câu. Nó không tự nghi ngờ giùm mình.

8. Chặn nhầm người thật là thua

Điều cuối cùng, và cũng là điều dễ quên nhất lúc đang hăng máu chống gian lận.

Cái ngưỡng tối đa bao nhiêu tài khoản trên một thiết bị nghe thì rất hợp lý. Nhưng thực chất nó đếm số tài khoản từng đăng nhập trên máy đó, nên nhà có bốn người dùng chung một cái máy tính bảng cũng dính, máy cũ bán lại cho người khác cũng dính, máy trưng bày ngoài cửa hàng cũng dính. Và người bị chặn thì chẳng hiểu chuyện gì đang xảy ra, họ chỉ thấy cái app báo lỗi.

Nên mình tự đặt ra mấy nguyên tắc. Ngưỡng phải để trong config chỉnh được lúc chạy chứ không hardcode, thấy chặn oan là hạ xuống được ngay khỏi cần deploy. Phải có công tắc tắt hẳn, đặt ngưỡng bằng không là gate ngừng hoạt động. Lỗi hệ thống thì cho qua chứ đừng chặn, vì query đếm mà lỗi database rồi lại đi chặn người dùng thật thì là đổi một rủi ro nhỏ lấy một thiệt hại lớn. Và thông báo lỗi phải nói được điều gì đó tử tế, chứ không phải quăng ra một cái 500 trống trơn.

Kết bài

Nhìn lại cả đợt này thì mình không thấy đây là câu chuyện AI code dở. Ngược lại là khác, phần lớn code nó viết ra sạch sẽ, có cấu trúc, comment tử tế hơn nhiều đoạn mình tự viết lúc vội. Vòng sửa vừa rồi cũng làm cùng AI, và chính nó tìm ra cái lỗi validate suýt chặn đường đăng ký.

Cái mình học được là AI rất giỏi trả lời câu hỏi, nhưng người phải là người đặt câu hỏi. Nó không tự mở database lên đếm xem cơ chế phòng thủ đã chạy lần nào chưa. Nó không tự hỏi tính năng này có ai gọi tới không. Nó không tự đi so hai luồng xoá tài khoản viết cách nhau mấy tháng. Những việc đó là của mình, và bỏ qua thì không AI nào gánh thay được.

Bỏ ngỏ lúc chưa có ai dùng thì mình vẫn nghĩ là hợp lý, chứ ngồi chống gian lận cho một sản phẩm không có user thì cũng chẳng để làm gì. Nhưng bỏ ngỏ mà tưởng là đã làm rồi thì lại là chuyện khác hẳn. Đó mới là thứ suýt làm mình trả giá.

Còn nếu bạn cũng đang có một lớp phòng thủ nào đó trong hệ thống mà thấy khá yên tâm về nó, nhất là phần do AI dựng, thì thử chạy giúp mình một câu query nhé. Xem nó đã chặn được ai lần nào chưa.

Và thôi đến đây thôi, chúc các bạn có những có những dòng code thật nà chất nượng! Xin chào và hẹn gặp lại các bạn ở những bài viết sau

#AI#Anti-fraud#System Design#Tập làm văn